destinyebuka commited on
Commit
45df327
·
1 Parent(s): d35fb02
app/__pycache__/config.cpython-313.pyc CHANGED
Binary files a/app/__pycache__/config.cpython-313.pyc and b/app/__pycache__/config.cpython-313.pyc differ
 
app/__pycache__/database.cpython-313.pyc CHANGED
Binary files a/app/__pycache__/database.cpython-313.pyc and b/app/__pycache__/database.cpython-313.pyc differ
 
app/ai/services/__pycache__/search_service.cpython-313.pyc CHANGED
Binary files a/app/ai/services/__pycache__/search_service.cpython-313.pyc and b/app/ai/services/__pycache__/search_service.cpython-313.pyc differ
 
app/integrations/guest_mcp.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import os
6
  import time
7
  from pathlib import Path
@@ -51,7 +52,21 @@ _WIDGET_PATH = (
51
  / "web"
52
  / "listing-cards.html"
53
  )
54
- _WIDGET_HTML = _WIDGET_PATH.read_text(encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  _RESOURCE_DOMAINS = [
56
  value.strip()
57
  for value in os.getenv(
 
2
 
3
  from __future__ import annotations
4
 
5
+ import base64
6
  import os
7
  import time
8
  from pathlib import Path
 
52
  / "web"
53
  / "listing-cards.html"
54
  )
55
+ _WIDGET_LOGO_PATH = _WIDGET_PATH.with_name("lojiz-logo.png")
56
+ _WIDGET_VERIFIED_PATH = _WIDGET_PATH.with_name("verified-badge.png")
57
+ _WIDGET_LOGO_DATA_URI = (
58
+ "data:image/png;base64,"
59
+ + base64.b64encode(_WIDGET_LOGO_PATH.read_bytes()).decode("ascii")
60
+ )
61
+ _WIDGET_VERIFIED_DATA_URI = (
62
+ "data:image/png;base64,"
63
+ + base64.b64encode(_WIDGET_VERIFIED_PATH.read_bytes()).decode("ascii")
64
+ )
65
+ _WIDGET_HTML = (
66
+ _WIDGET_PATH.read_text(encoding="utf-8")
67
+ .replace("__LOJIZ_LOGO_DATA_URI__", _WIDGET_LOGO_DATA_URI)
68
+ .replace("__LOJIZ_VERIFIED_DATA_URI__", _WIDGET_VERIFIED_DATA_URI)
69
+ )
70
  _RESOURCE_DOMAINS = [
71
  value.strip()
72
  for value in os.getenv(
app/models/__pycache__/listing.cpython-313.pyc CHANGED
Binary files a/app/models/__pycache__/listing.cpython-313.pyc and b/app/models/__pycache__/listing.cpython-313.pyc differ
 
app/schemas/guest_discovery.py CHANGED
@@ -103,9 +103,10 @@ class GuestListingCard(BaseModel):
103
  video: Optional[str] = None
104
  rating: float = 0.0
105
  reviews_count: int = 0
 
 
106
  host_verified: bool = False
107
  availability: str = "available"
108
- match_reason: str
109
  public_url: str
110
  action_label: str
111
 
@@ -115,6 +116,7 @@ class GuestDiscoveryResponse(BaseModel):
115
  guest_mode: bool = True
116
  powered_by: str = "AIDA on Lojiz"
117
  provider: str
 
118
  mode: Literal["search", "recommendation", "similar"]
119
  message: str
120
  listings: list[GuestListingCard]
@@ -133,6 +135,7 @@ class GuestComparisonResponse(BaseModel):
133
  guest_mode: bool = True
134
  powered_by: str = "AIDA on Lojiz"
135
  provider: str
 
136
  message: str
137
  recommended_public_id: Optional[str] = None
138
  items: list[GuestComparisonItem]
 
103
  video: Optional[str] = None
104
  rating: float = 0.0
105
  reviews_count: int = 0
106
+ host_name: str = "Lojiz host"
107
+ host_avatar: Optional[str] = None
108
  host_verified: bool = False
109
  availability: str = "available"
 
110
  public_url: str
111
  action_label: str
112
 
 
116
  guest_mode: bool = True
117
  powered_by: str = "AIDA on Lojiz"
118
  provider: str
119
+ language: str = "en"
120
  mode: Literal["search", "recommendation", "similar"]
121
  message: str
122
  listings: list[GuestListingCard]
 
135
  guest_mode: bool = True
136
  powered_by: str = "AIDA on Lojiz"
137
  provider: str
138
+ language: str = "en"
139
  message: str
140
  recommended_public_id: Optional[str] = None
141
  items: list[GuestComparisonItem]
app/services/guest_discovery_service.py CHANGED
@@ -38,14 +38,6 @@ _ACTION_LABELS = {
38
  "ar": "عرض على Lojiz",
39
  }
40
 
41
- _GENERIC_REASONS = {
42
- "en": "AIDA ranked this as a strong match for your request.",
43
- "fr": "AIDA a classé ce bien parmi les meilleures correspondances.",
44
- "es": "AIDA clasificó esta propiedad entre las mejores coincidencias.",
45
- "pt": "A AIDA classificou este imóvel entre as melhores opções.",
46
- "ar": "صنفت AIDA هذا العقار ضمن أفضل النتائج لطلبك.",
47
- }
48
-
49
  _COMPARISON_FALLBACK = {
50
  "en": "AIDA compared these listings using their price, location, space, amenities, photos, and ratings.",
51
  "fr": "AIDA a comparé ces biens selon le prix, la localisation, l'espace, les équipements, les photos et les avis.",
@@ -125,65 +117,6 @@ def _with_attribution(public_url: str, provider: str) -> str:
125
  return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
126
 
127
 
128
- def _match_reason(listing: dict[str, Any], params: dict[str, Any], language: str) -> str:
129
- language = language if language in _GENERIC_REASONS else "en"
130
- note = _summary(listing.get("_match_note"), max_length=240)
131
- if note:
132
- return note
133
-
134
- details: list[str] = []
135
- location = str(params.get("location") or "").strip()
136
- if location and location.lower() in str(listing.get("location") or "").lower():
137
- details.append({
138
- "en": "the requested location",
139
- "fr": "la zone recherchée",
140
- "es": "la ubicación solicitada",
141
- "pt": "a localização pedida",
142
- "ar": "الموقع المطلوب",
143
- }[language])
144
- max_price = _safe_number(params.get("max_price"))
145
- price = _safe_number(listing.get("price"))
146
- if max_price and price and price <= max_price:
147
- details.append({
148
- "en": "your budget",
149
- "fr": "votre budget",
150
- "es": "tu presupuesto",
151
- "pt": "o seu orçamento",
152
- "ar": "ميزانيتك",
153
- }[language])
154
- requested_beds = int(_safe_number(params.get("bedrooms")))
155
- if requested_beds and int(_safe_number(listing.get("bedrooms"))) >= requested_beds:
156
- details.append({
157
- "en": "the space you need",
158
- "fr": "l'espace demandé",
159
- "es": "el espacio que necesitas",
160
- "pt": "o espaço de que precisa",
161
- "ar": "المساحة التي تحتاجها",
162
- }[language])
163
- requested_amenities = {str(item).lower() for item in params.get("amenities") or []}
164
- listing_amenities = {str(item).lower() for item in listing.get("amenities") or []}
165
- if requested_amenities and requested_amenities.intersection(listing_amenities):
166
- details.append({
167
- "en": "requested amenities",
168
- "fr": "les équipements souhaités",
169
- "es": "los servicios solicitados",
170
- "pt": "as comodidades pedidas",
171
- "ar": "المرافق المطلوبة",
172
- }[language])
173
-
174
- if not details:
175
- return _GENERIC_REASONS[language]
176
- joined = ", ".join(details[:3])
177
- templates = {
178
- "en": f"AIDA ranked it highly for {joined}.",
179
- "fr": f"AIDA l'a bien classé pour {joined}.",
180
- "es": f"AIDA la clasificó muy bien por {joined}.",
181
- "pt": f"A AIDA classificou-o bem por {joined}.",
182
- "ar": f"صنفته AIDA جيداً بسبب {joined}.",
183
- }
184
- return templates[language]
185
-
186
-
187
  def _is_publicly_available(listing: dict[str, Any]) -> bool:
188
  if str(listing.get("status") or "").lower() != "active":
189
  return False
@@ -220,10 +153,8 @@ async def _authoritative_listing(raw: dict[str, Any]) -> Optional[dict[str, Any]
220
  async def listing_to_guest_card(
221
  raw: dict[str, Any],
222
  *,
223
- params: dict[str, Any],
224
  language: str,
225
  provider: str,
226
- fallback_reason: Optional[str] = None,
227
  ) -> Optional[GuestListingCard]:
228
  listing = await _authoritative_listing(raw)
229
  if not listing:
@@ -236,6 +167,7 @@ async def listing_to_guest_card(
236
 
237
  images = _public_media(listing.get("images") or [], limit=3)
238
  videos = _public_media(listing.get("videos") or [], limit=1)
 
239
  summary = _summary(listing.get("description")) or _summary(listing.get("title"))
240
  amenities = [
241
  _summary(value, max_length=50)
@@ -260,9 +192,10 @@ async def listing_to_guest_card(
260
  video=videos[0] if videos else None,
261
  rating=_safe_number(listing.get("rating")),
262
  reviews_count=int(_safe_number(listing.get("reviews_count"))),
 
 
263
  host_verified=bool(listing.get("owner_is_verified")),
264
  availability=str(listing.get("availability_status") or "available"),
265
- match_reason=fallback_reason or _match_reason(listing, params, language),
266
  public_url=_with_attribution(public_url, provider),
267
  action_label=_ACTION_LABELS.get(language, _ACTION_LABELS["en"]),
268
  )
@@ -282,7 +215,6 @@ async def discovery_to_guest_response(
282
  for raw in discovery.listings:
283
  card = await listing_to_guest_card(
284
  raw,
285
- params=discovery.search_params,
286
  language=language,
287
  provider=provider,
288
  )
@@ -294,6 +226,7 @@ async def discovery_to_guest_response(
294
 
295
  return GuestDiscoveryResponse(
296
  provider=provider,
 
297
  mode=mode,
298
  message=_summary(discovery.message, max_length=900),
299
  listings=cards,
@@ -430,10 +363,8 @@ async def compare_guest_listings(
430
  continue
431
  card = await listing_to_guest_card(
432
  listing,
433
- params={},
434
  language=language,
435
  provider=provider,
436
- fallback_reason=_GENERIC_REASONS.get(language, _GENERIC_REASONS["en"]),
437
  )
438
  if card:
439
  cards.append(card)
@@ -500,6 +431,7 @@ Do not invent facts. Every strength must be supported by the supplied data.
500
  ]
501
  return GuestComparisonResponse(
502
  provider=provider,
 
503
  message=message,
504
  recommended_public_id=cards[winner].public_id,
505
  items=items,
 
38
  "ar": "عرض على Lojiz",
39
  }
40
 
 
 
 
 
 
 
 
 
41
  _COMPARISON_FALLBACK = {
42
  "en": "AIDA compared these listings using their price, location, space, amenities, photos, and ratings.",
43
  "fr": "AIDA a comparé ces biens selon le prix, la localisation, l'espace, les équipements, les photos et les avis.",
 
117
  return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
118
 
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  def _is_publicly_available(listing: dict[str, Any]) -> bool:
121
  if str(listing.get("status") or "").lower() != "active":
122
  return False
 
153
  async def listing_to_guest_card(
154
  raw: dict[str, Any],
155
  *,
 
156
  language: str,
157
  provider: str,
 
158
  ) -> Optional[GuestListingCard]:
159
  listing = await _authoritative_listing(raw)
160
  if not listing:
 
167
 
168
  images = _public_media(listing.get("images") or [], limit=3)
169
  videos = _public_media(listing.get("videos") or [], limit=1)
170
+ host_avatars = _public_media([listing.get("owner_profile_picture")], limit=1)
171
  summary = _summary(listing.get("description")) or _summary(listing.get("title"))
172
  amenities = [
173
  _summary(value, max_length=50)
 
192
  video=videos[0] if videos else None,
193
  rating=_safe_number(listing.get("rating")),
194
  reviews_count=int(_safe_number(listing.get("reviews_count"))),
195
+ host_name=_summary(listing.get("owner_name") or "Lojiz host", max_length=80),
196
+ host_avatar=host_avatars[0] if host_avatars else None,
197
  host_verified=bool(listing.get("owner_is_verified")),
198
  availability=str(listing.get("availability_status") or "available"),
 
199
  public_url=_with_attribution(public_url, provider),
200
  action_label=_ACTION_LABELS.get(language, _ACTION_LABELS["en"]),
201
  )
 
215
  for raw in discovery.listings:
216
  card = await listing_to_guest_card(
217
  raw,
 
218
  language=language,
219
  provider=provider,
220
  )
 
226
 
227
  return GuestDiscoveryResponse(
228
  provider=provider,
229
+ language=language,
230
  mode=mode,
231
  message=_summary(discovery.message, max_length=900),
232
  listings=cards,
 
363
  continue
364
  card = await listing_to_guest_card(
365
  listing,
 
366
  language=language,
367
  provider=provider,
 
368
  )
369
  if card:
370
  cards.append(card)
 
431
  ]
432
  return GuestComparisonResponse(
433
  provider=provider,
434
+ language=language,
435
  message=message,
436
  recommended_public_id=cards[winner].public_id,
437
  items=items,
integrations/lojiz_guest_mcp/src/server.ts CHANGED
@@ -29,8 +29,16 @@ const ALLOWED_HOSTS = new Set(
29
 
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
  const widgetPath = path.resolve(__dirname, "..", "web", "listing-cards.html");
32
- const widgetHtml = fs.readFileSync(widgetPath, "utf8");
33
-
 
 
 
 
 
 
 
 
34
  const languageSchema = z.enum(["en", "fr", "es", "pt", "ar"]).default("en");
35
  const listingCardSchema = z.object({
36
  public_id: z.string(),
@@ -49,9 +57,10 @@ const listingCardSchema = z.object({
49
  video: z.string().url().nullable().optional(),
50
  rating: z.number(),
51
  reviews_count: z.number().int(),
 
 
52
  host_verified: z.boolean(),
53
  availability: z.string(),
54
- match_reason: z.string(),
55
  public_url: z.string().url(),
56
  action_label: z.string(),
57
  });
@@ -61,6 +70,7 @@ const discoveryOutputSchema = {
61
  guest_mode: z.boolean(),
62
  powered_by: z.string(),
63
  provider: z.string(),
 
64
  mode: z.enum(["search", "recommendation", "similar"]),
65
  message: z.string(),
66
  listings: z.array(listingCardSchema),
@@ -74,6 +84,7 @@ const comparisonOutputSchema = {
74
  guest_mode: z.boolean(),
75
  powered_by: z.string(),
76
  provider: z.string(),
 
77
  message: z.string(),
78
  recommended_public_id: z.string().nullable().optional(),
79
  items: z.array(z.object({ listing: listingCardSchema, strengths: z.array(z.string()) })),
 
29
 
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
  const widgetPath = path.resolve(__dirname, "..", "web", "listing-cards.html");
32
+ const widgetLogoPath = path.resolve(__dirname, "..", "web", "lojiz-logo.png");
33
+ const widgetVerifiedPath = path.resolve(__dirname, "..", "web", "verified-badge.png");
34
+ const widgetLogoDataUri =
35
+ "data:image/png;base64," + fs.readFileSync(widgetLogoPath).toString("base64");
36
+ const widgetVerifiedDataUri =
37
+ "data:image/png;base64," + fs.readFileSync(widgetVerifiedPath).toString("base64");
38
+ const widgetHtml = fs
39
+ .readFileSync(widgetPath, "utf8")
40
+ .replace("__LOJIZ_LOGO_DATA_URI__", widgetLogoDataUri)
41
+ .replace("__LOJIZ_VERIFIED_DATA_URI__", widgetVerifiedDataUri);
42
  const languageSchema = z.enum(["en", "fr", "es", "pt", "ar"]).default("en");
43
  const listingCardSchema = z.object({
44
  public_id: z.string(),
 
57
  video: z.string().url().nullable().optional(),
58
  rating: z.number(),
59
  reviews_count: z.number().int(),
60
+ host_name: z.string(),
61
+ host_avatar: z.string().url().nullable().optional(),
62
  host_verified: z.boolean(),
63
  availability: z.string(),
 
64
  public_url: z.string().url(),
65
  action_label: z.string(),
66
  });
 
70
  guest_mode: z.boolean(),
71
  powered_by: z.string(),
72
  provider: z.string(),
73
+ language: z.string(),
74
  mode: z.enum(["search", "recommendation", "similar"]),
75
  message: z.string(),
76
  listings: z.array(listingCardSchema),
 
84
  guest_mode: z.boolean(),
85
  powered_by: z.string(),
86
  provider: z.string(),
87
+ language: z.string(),
88
  message: z.string(),
89
  recommended_public_id: z.string().nullable().optional(),
90
  items: z.array(z.object({ listing: listingCardSchema, strengths: z.array(z.string()) })),
integrations/lojiz_guest_mcp/web/listing-cards.html CHANGED
@@ -6,167 +6,344 @@
6
  <style>
7
  :root {
8
  color-scheme: light dark;
9
- --bg: #ffffff;
10
- --surface: #f7f7fa;
11
- --text: #17131f;
12
- --muted: #6e6878;
13
- --line: rgba(23, 19, 31, 0.11);
14
- --purple: #8e44ad;
15
- --pink: #ff2f68;
 
 
 
16
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
17
  }
18
  @media (prefers-color-scheme: dark) {
19
- :root { --bg: #151219; --surface: #211d26; --text: #fbf8ff; --muted: #b8b0c0; --line: rgba(255,255,255,.12); }
 
 
 
 
 
 
 
20
  }
21
  * { box-sizing: border-box; }
22
- body { margin: 0; background: var(--bg); color: var(--text); }
23
- .shell { padding: 12px; }
24
- .header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
 
25
  .brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
26
- .mark { display: grid; place-items: center; width: 32px; height: 32px; flex: 0 0 32px; border-radius: 8px; color: white; font-weight: 800; background: linear-gradient(135deg, var(--purple), var(--pink)); }
27
- h1 { margin: 0; font-size: 15px; line-height: 1.2; letter-spacing: 0; }
28
- .powered { margin-top: 3px; color: var(--muted); font-size: 11px; }
29
- .count { flex: 0 0 auto; color: var(--muted); font-size: 11px; padding-top: 3px; }
30
- .summary { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.45; }
31
- .cards { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(230px, 78%); gap: 10px; overflow-x: auto; scroll-snap-type: x mandatory; padding: 1px 1px 7px; scrollbar-width: thin; }
32
- .card { scroll-snap-align: start; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); min-width: 0; }
33
- .media { position: relative; aspect-ratio: 4 / 3; background: color-mix(in srgb, var(--surface), var(--text) 7%); overflow: hidden; }
34
- .media img { width: 100%; height: 100%; display: block; object-fit: cover; }
35
- .media-fallback { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); font-size: 12px; }
36
- .media img:not([src]), .media img[src=""] { display: none; }
37
- .type { position: absolute; left: 8px; top: 8px; max-width: calc(100% - 16px); padding: 4px 7px; border-radius: 6px; background: rgba(17, 13, 23, .78); color: white; font-size: 10px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
38
- .body { padding: 10px; }
39
- .title { min-height: 38px; margin: 0; font-size: 14px; line-height: 1.35; font-weight: 750; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
40
- .location { margin-top: 5px; color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
41
- .price { margin-top: 9px; font-size: 14px; font-weight: 800; }
42
- .period { margin-left: 3px; color: var(--muted); font-size: 10px; font-weight: 500; }
43
- .facts { display: flex; flex-wrap: wrap; gap: 5px; min-height: 24px; margin-top: 8px; }
44
- .fact { border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px; color: var(--muted); font-size: 10px; }
45
- .reason { margin: 9px 0 0; border-top: 1px solid var(--line); padding-top: 8px; color: var(--text); font-size: 11px; line-height: 1.4; }
46
- .reason strong { color: var(--pink); }
47
- .strengths { margin: 7px 0 0; padding-left: 17px; color: var(--muted); font-size: 10px; line-height: 1.45; }
48
- .cta { display: flex; align-items: center; justify-content: center; width: 100%; min-height: 38px; margin-top: 10px; border-radius: 7px; color: white; text-decoration: none; font-size: 12px; font-weight: 750; background: linear-gradient(100deg, var(--purple), var(--pink)); }
49
- .empty { padding: 24px 14px; border: 1px solid var(--line); border-radius: 8px; text-align: center; color: var(--muted); font-size: 12px; }
50
- @media (min-width: 680px) {
51
- .cards { grid-auto-flow: initial; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: visible; }
52
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  </style>
54
  </head>
55
  <body>
56
  <main class="shell">
57
- <div class="header">
58
- <div class="brand">
59
- <div class="mark" aria-hidden="true">L</div>
60
- <div><h1 id="heading">Lojiz properties</h1><div class="powered" id="powered">Powered by AIDA on Lojiz</div></div>
61
- </div>
62
- <div class="count" id="count"></div>
 
 
63
  </div>
64
- <p class="summary" id="summary"></p>
65
- <section class="cards" id="cards" aria-live="polite"></section>
66
  </main>
67
  <script>
68
  (function () {
 
 
69
  var labels = {
70
- en: { heading: "Properties selected for you", result: "result", results: "results", reason: "Why AIDA chose it", beds: "beds", baths: "baths", guests: "guests", empty: "No matching public listings are available right now." },
71
- fr: { heading: "Des biens sélectionnés pour vous", result: "résultat", results: "résultats", reason: "Pourquoi AIDA le recommande", beds: "chambres", baths: "salles de bain", guests: "voyageurs", empty: "Aucun bien public correspondant n'est disponible pour le moment." },
72
- es: { heading: "Propiedades elegidas para ti", result: "resultado", results: "resultados", reason: "Por qué AIDA la eligió", beds: "habitaciones", baths: "baños", guests: "huéspedes", empty: "No hay propiedades públicas coincidentes disponibles ahora." },
73
- pt: { heading: "Imóveis escolhidos para si", result: "resultado", results: "resultados", reason: "Por que a AIDA escolheu", beds: "quartos", baths: "casas de banho", guests: "hóspedes", empty: "Não há imóveis públicos correspondentes disponíveis agora." },
74
- ar: { heading: "عقارات مختارة لك", result: "نتيجة", results: "نتائج", reason: "لماذا اختارته AIDA", beds: "غرف", baths: "حمامات", guests: "ضيوف", empty: "لا توجد عقارات عامة مطابقة متاحة حالياً." }
75
  };
76
- var locale = (document.documentElement.lang || "en").toLowerCase().split("-")[0];
77
- if (!labels[locale]) locale = "en";
78
- var t = labels[locale];
79
- var heading = document.getElementById("heading");
80
- var powered = document.getElementById("powered");
81
- var count = document.getElementById("count");
82
  var summary = document.getElementById("summary");
83
  var cards = document.getElementById("cards");
84
 
85
  function text(value) { return value == null ? "" : String(value); }
86
- function element(tag, className, value) {
87
- var node = document.createElement(tag);
88
- if (className) node.className = className;
89
- if (value !== undefined) node.textContent = text(value);
90
- return node;
 
 
 
 
 
 
 
 
91
  }
92
- function price(card) {
93
- try { return new Intl.NumberFormat(document.documentElement.lang || "en", { maximumFractionDigits: 0 }).format(Number(card.price || 0)) + " " + text(card.currency); }
94
- catch (_) { return text(card.currency) + " " + text(card.price); }
 
 
 
 
 
95
  }
96
- function period(value) {
97
- var clean = text(value).replace(/_/g, " ").trim();
98
- return clean ? "/ " + clean : "";
 
99
  }
100
- function addFact(container, value, label) {
101
- if (value === null || value === undefined || value === 0) return;
102
- container.appendChild(element("span", "fact", text(value) + " " + label));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  }
104
- function cardNode(card, strengths) {
 
 
 
 
 
 
 
105
  var article = element("article", "card");
 
 
 
 
 
 
 
106
  var media = element("div", "media");
107
- var fallback = element("div", "media-fallback", "Lojiz");
 
 
 
 
 
 
 
108
  media.appendChild(fallback);
 
 
 
109
  var image = document.createElement("img");
 
110
  image.alt = text(card.title);
111
  image.loading = "lazy";
112
  image.referrerPolicy = "no-referrer";
113
- image.addEventListener("load", function () { fallback.hidden = true; });
114
- image.addEventListener("error", function () { image.remove(); fallback.hidden = false; });
115
- if (Array.isArray(card.images) && card.images[0]) image.src = text(card.images[0]);
116
  media.appendChild(image);
117
- media.appendChild(element("span", "type", text(card.listing_type).replace(/[-_]/g, " ")));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  article.appendChild(media);
119
 
120
- var body = element("div", "body");
121
- body.appendChild(element("h2", "title", card.title));
122
- body.appendChild(element("div", "location", card.location));
123
- var priceLine = element("div", "price", price(card));
124
- priceLine.appendChild(element("span", "period", period(card.price_type)));
125
- body.appendChild(priceLine);
 
 
 
126
  var facts = element("div", "facts");
127
  addFact(facts, card.bedrooms, t.beds);
128
  addFact(facts, card.bathrooms, t.baths);
129
  addFact(facts, card.max_guests, t.guests);
130
- body.appendChild(facts);
131
- var reason = element("p", "reason");
132
- reason.appendChild(element("strong", "", t.reason + ": "));
133
- reason.appendChild(document.createTextNode(text(card.match_reason)));
134
- body.appendChild(reason);
135
- if (Array.isArray(strengths) && strengths.length) {
136
- var list = element("ul", "strengths");
137
- strengths.slice(0, 3).forEach(function (value) { var li = element("li", "", value); list.appendChild(li); });
138
- body.appendChild(list);
 
 
139
  }
140
- var link = element("a", "cta", card.action_label || "View on Lojiz");
141
- link.href = text(card.public_url);
142
- link.target = "_blank";
143
- link.rel = "noopener noreferrer";
144
- body.appendChild(link);
145
- article.appendChild(body);
 
 
 
 
 
 
 
146
  return article;
147
  }
 
148
  function render(data) {
149
  data = data && typeof data === "object" ? data : {};
 
 
 
 
150
  var rows = Array.isArray(data.listings) ? data.listings.map(function (listing) { return { listing: listing, strengths: [] }; }) : [];
151
  if (!rows.length && Array.isArray(data.items)) rows = data.items;
152
- heading.textContent = t.heading;
153
- powered.textContent = data.powered_by ? "Powered by " + text(data.powered_by) : "Powered by AIDA on Lojiz";
154
- count.textContent = rows.length + " " + (rows.length === 1 ? t.result : t.results);
155
- summary.textContent = text(data.message);
 
 
 
 
156
  cards.replaceChildren();
157
- if (!rows.length) { cards.appendChild(element("div", "empty", t.empty)); return; }
158
- rows.forEach(function (row) { if (row && row.listing) cards.appendChild(cardNode(row.listing, row.strengths)); });
 
159
  }
160
 
161
- window.addEventListener("message", function (event) {
162
- if (event.source !== window.parent) return;
163
- var message = event.data;
164
- if (!message || message.jsonrpc !== "2.0") return;
165
- if (message.method === "ui/notifications/tool-result") render(message.params && message.params.structuredContent);
166
- }, { passive: true });
167
-
168
- if (window.openai && window.openai.toolOutput) render(window.openai.toolOutput);
169
- else render(null);
170
  })();
171
  </script>
172
  </body>
 
6
  <style>
7
  :root {
8
  color-scheme: light dark;
9
+ --canvas: #ffffff;
10
+ --surface: #ffffff;
11
+ --surface-soft: #f7f6f9;
12
+ --text: #1d1824;
13
+ --muted: #746e7d;
14
+ --line: rgba(29, 24, 36, 0.11);
15
+ --pink: #f52e62;
16
+ --magenta: #cc377e;
17
+ --purple: #8f489f;
18
+ --success: #17a66f;
19
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20
  }
21
  @media (prefers-color-scheme: dark) {
22
+ :root {
23
+ --canvas: #121014;
24
+ --surface: #1b181e;
25
+ --surface-soft: #242028;
26
+ --text: #fbf8fd;
27
+ --muted: #b8b1bd;
28
+ --line: rgba(255, 255, 255, 0.12);
29
+ }
30
  }
31
  * { box-sizing: border-box; }
32
+ [hidden] { display: none !important; }
33
+ body { margin: 0; background: var(--canvas); color: var(--text); }
34
+ .shell { width: 100%; padding: 12px; }
35
+ .header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
36
  .brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
37
+ .logo { width: 36px; height: 36px; flex: 0 0 36px; object-fit: contain; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
38
+ .brand-copy { min-width: 0; }
39
+ h1 { margin: 0; font-size: 15px; line-height: 1.2; font-weight: 800; letter-spacing: 0; }
40
+ .powered { margin-top: 3px; color: var(--muted); font-size: 11px; line-height: 1.25; }
41
+ .count { flex: 0 0 auto; border: 1px solid var(--line); border-radius: 999px; padding: 5px 8px; color: var(--muted); font-size: 10px; font-weight: 700; }
42
+ .aida-note { display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: center; gap: 9px; margin-bottom: 12px; }
43
+ .aida-note[hidden] { display: none; }
44
+ .aida-note-logo { width: 32px; height: 32px; border: 1px solid var(--line); border-radius: 8px; background: #fff; object-fit: contain; }
45
+ .summary { margin: 0; max-width: 780px; color: var(--text); font-size: 12px; line-height: 1.45; }
46
+ .cards-wrap { position: relative; }
47
+ .cards { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(250px, 82%); gap: 10px; overflow-x: auto; scroll-behavior: smooth; scroll-snap-type: x mandatory; overscroll-behavior-inline: contain; padding: 1px; scrollbar-width: none; }
48
+ .cards::-webkit-scrollbar { display: none; }
49
+ .cards-nav { position: absolute; top: 50%; z-index: 5; display: grid; place-items: center; width: 34px; height: 48px; margin-top: -24px; border: 1px solid rgba(255,255,255,.28); border-radius: 8px; background: rgba(17,13,21,.76); backdrop-filter: blur(10px); color: #fff; cursor: pointer; opacity: 0; box-shadow: 0 7px 22px rgba(0,0,0,.24); transition: opacity 150ms ease, transform 150ms ease, background 150ms ease; }
50
+ .cards-wrap:hover .cards-nav:not(:disabled), .cards-nav:focus-visible { opacity: 1; }
51
+ .cards-nav:hover { background: rgba(17,13,21,.92); transform: translateY(-1px); }
52
+ .cards-nav:disabled { opacity: 0; pointer-events: none; }
53
+ .cards-nav.previous { left: 8px; }
54
+ .cards-nav.next { right: 8px; }
55
+ .cards-nav svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
56
+ .card { scroll-snap-align: start; cursor: pointer; min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: inherit; text-decoration: none; box-shadow: 0 8px 24px rgba(24, 18, 31, 0.08); transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease; }
57
+ .card:hover { transform: translateY(-2px); border-color: rgba(204, 55, 126, 0.42); box-shadow: 0 12px 28px rgba(24, 18, 31, 0.13); }
58
+ .card:focus-visible { outline: 3px solid rgba(245, 46, 98, 0.28); outline-offset: 2px; }
59
+ .media { position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: var(--surface-soft); }
60
+ .media-image { width: 100%; height: 100%; display: block; object-fit: cover; opacity: 1; transition: opacity 220ms ease, transform 240ms ease; }
61
+ .media-image.changing { opacity: .18; }
62
+ .card:hover .media-image { transform: scale(1.02); }
63
+ .media-shade { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(12,8,16,.35) 0%, rgba(12,8,16,0) 40%, rgba(12,8,16,.42) 100%); pointer-events: none; }
64
+ .media-fallback { position: absolute; inset: 0; display: grid; place-items: center; background: var(--surface-soft); }
65
+ .fallback-content { display: grid; place-items: center; gap: 6px; color: var(--muted); opacity: .58; }
66
+ .fallback-mark { position: relative; display: grid; place-items: center; width: 66px; height: 52px; }
67
+ .fallback-image-icon { width: 54px; height: 42px; fill: none; stroke: currentColor; stroke-width: 1.45; stroke-linecap: round; stroke-linejoin: round; opacity: .48; }
68
+ .fallback-logo { position: absolute; right: -2px; bottom: -4px; width: 25px; height: 25px; border: 1px solid var(--line); border-radius: 6px; background: #fff; object-fit: contain; padding: 2px; }
69
+ .fallback-label { max-width: 130px; font-size: 9px; font-weight: 700; text-align: center; }
70
+
71
+ .type { position: absolute; top: 9px; left: 9px; max-width: calc(100% - 70px); border: 1px solid rgba(255,255,255,.22); border-radius: 999px; padding: 5px 8px; background: rgba(16,12,21,.68); backdrop-filter: blur(8px); color: #fff; font-size: 9px; line-height: 1; font-weight: 800; text-transform: capitalize; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
72
+
73
+ .carousel-button { position: absolute; top: 50%; z-index: 2; display: grid; place-items: center; width: 30px; height: 30px; margin-top: -15px; border: 1px solid rgba(255,255,255,.32); border-radius: 50%; background: rgba(16,12,21,.58); backdrop-filter: blur(10px); color: #fff; cursor: pointer; opacity: 0; box-shadow: 0 4px 14px rgba(0,0,0,.18); transition: opacity 140ms ease, background 140ms ease, transform 140ms ease; }
74
+ .carousel-button svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
75
+ .card:hover .carousel-button, .carousel-button:focus-visible { opacity: 1; }
76
+ .carousel-button:hover { background: rgba(16,12,21,.82); transform: scale(1.04); }
77
+ .carousel-button.previous { left: 8px; }
78
+ .carousel-button.next { right: 8px; }
79
+ .dots { position: absolute; left: 50%; bottom: 10px; z-index: 2; display: flex; align-items: center; gap: 4px; border-radius: 999px; padding: 4px 6px; background: rgba(16,12,21,.46); transform: translateX(-50%); }
80
+ .dot { width: 5px; height: 5px; border: 0; border-radius: 50%; padding: 0; background: rgba(255,255,255,.52); cursor: pointer; transition: width 140ms ease, background 140ms ease; }
81
+ .dot.active { width: 13px; border-radius: 999px; background: #fff; }
82
+ @media (hover: none) { .carousel-button { display: none; } }
83
+ .content { padding: 11px; }
84
+ .title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
85
+ .title { min-height: 36px; margin: 0; font-size: 13px; line-height: 1.35; font-weight: 820; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
86
+ .rating { flex: 0 0 auto; display: inline-flex; align-items: center; border: 1px solid transparent; border-radius: 999px; padding: 4px 6px; background: linear-gradient(var(--surface-soft), var(--surface-soft)) padding-box, linear-gradient(135deg, var(--purple), var(--magenta), var(--pink)) border-box; font-size: 10px; font-weight: 800; }
87
+ .rating-value { background: linear-gradient(135deg, var(--purple), var(--magenta), var(--pink)); background-clip: text; -webkit-background-clip: text; color: transparent; -webkit-text-fill-color: transparent; }
88
+
89
+ .location { display: flex; align-items: center; gap: 4px; min-width: 0; margin-top: 5px; color: var(--muted); font-size: 10.5px; }
90
+ .location-icon { width: 12px; height: 12px; flex: 0 0 12px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
91
+ .location-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
92
+ .facts { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 9px; }
93
+ .fact { border-radius: 5px; padding: 4px 6px; background: var(--surface-soft); color: var(--muted); font-size: 9.5px; font-weight: 650; }
94
+ .footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; border-top: 1px solid var(--line); padding-top: 10px; }
95
+ .price-wrap { min-width: 0; }
96
+ .price-line { display: flex; align-items: center; gap: 5px; min-width: 0; }
97
+ .price { overflow: hidden; color: var(--text); font-size: 14px; line-height: 1.1; font-weight: 880; text-overflow: ellipsis; white-space: nowrap; }
98
+ .period { margin-top: 3px; color: var(--muted); font-size: 9.5px; }
99
+ .verified-price { display: block; flex: 0 0 15px; width: 15px; height: 15px; object-fit: contain; }
100
+ .host { position: relative; display: inline-grid; place-items: center; width: 34px; height: 34px; flex: 0 0 34px; border-radius: 50%; background: linear-gradient(135deg, var(--purple), var(--magenta), var(--pink)); padding: 2px; box-shadow: 0 4px 12px rgba(204,55,126,.22); }
101
+ .host-inner { position: relative; display: grid; place-items: center; width: 100%; height: 100%; overflow: hidden; border: 2px solid var(--surface); border-radius: 50%; background: var(--surface-soft); color: var(--text); font-size: 9px; font-weight: 850; }
102
+ .host-image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
103
+
104
+ .empty { padding: 28px 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-soft); text-align: center; color: var(--muted); font-size: 12px; }
105
+ @media (max-width: 360px) { .cards { grid-auto-columns: 90%; } .summary { font-size: 11.5px; } }
106
+ @media (min-width: 760px) { .cards { grid-auto-flow: initial; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: visible; } .cards-nav { display: none; } }
107
+ @media (prefers-reduced-motion: reduce) { .card, .media-image { transition: none; } }
108
  </style>
109
  </head>
110
  <body>
111
  <main class="shell">
112
+ <section class="aida-note" id="aidaNote">
113
+ <img class="aida-note-logo" src="__LOJIZ_LOGO_DATA_URI__" alt="AIDA on Lojiz" />
114
+ <p class="summary" id="summary"></p>
115
+ </section>
116
+ <div class="cards-wrap">
117
+ <button class="cards-nav previous" id="cardsPrevious" type="button"></button>
118
+ <section class="cards" id="cards" aria-live="polite"></section>
119
+ <button class="cards-nav next" id="cardsNext" type="button"></button>
120
  </div>
 
 
121
  </main>
122
  <script>
123
  (function () {
124
+ var logoDataUri = "__LOJIZ_LOGO_DATA_URI__";
125
+ var verifiedDataUri = "__LOJIZ_VERIFIED_DATA_URI__";
126
  var labels = {
127
+ en: { heading: "Properties selected for you", selected: "Selected by AIDA on Lojiz", result: "property", results: "properties", reason: "Why this fits", beds: "beds", baths: "baths", guests: "guests", photos: "photos", empty: "No matching public properties are available right now.", rent: "For rent", sale: "For sale", roommate: "Roommate", shortStay: "Short stay", month: "per month", night: "per night", year: "per year", verified: "Verified host", imageUnavailable: "Photo unavailable", previousPhoto: "Previous photo", nextPhoto: "Next photo", photo: "Photo", previousResults: "Previous properties", nextResults: "Next properties" },
128
+ fr: { heading: "Des biens sélectionnés pour vous", selected: "Sélectionnés par AIDA sur Lojiz", result: "bien", results: "biens", reason: "Pourquoi ce bien convient", beds: "chambres", baths: "salles de bain", guests: "voyageurs", photos: "photos", empty: "Aucun bien public correspondant n'est disponible pour le moment.", rent: "À louer", sale: "À vendre", roommate: "Colocation", shortStay: "Court séjour", month: "par mois", night: "par nuit", year: "par an", verified: "Hôte vérifié", imageUnavailable: "Photo indisponible", previousPhoto: "Photo précédente", nextPhoto: "Photo suivante", photo: "Photo", previousResults: "Biens précédents", nextResults: "Biens suivants" },
129
+ es: { heading: "Propiedades elegidas para ti", selected: "Seleccionadas por AIDA en Lojiz", result: "propiedad", results: "propiedades", reason: "Por qué encaja", beds: "habitaciones", baths: "baños", guests: "huéspedes", photos: "fotos", empty: "No hay propiedades públicas coincidentes disponibles ahora.", rent: "En alquiler", sale: "En venta", roommate: "Compañero de piso", shortStay: "Corta estancia", month: "al mes", night: "por noche", year: "al año", verified: "Anfitrión verificado", imageUnavailable: "Foto no disponible", previousPhoto: "Foto anterior", nextPhoto: "Foto siguiente", photo: "Foto", previousResults: "Propiedades anteriores", nextResults: "Propiedades siguientes" },
130
+ pt: { heading: "Imóveis escolhidos para si", selected: "Selecionados pela AIDA no Lojiz", result: "imóvel", results: "imóveis", reason: "Por que combina", beds: "quartos", baths: "casas de banho", guests: "hóspedes", photos: "fotos", empty: "Não há imóveis públicos correspondentes disponíveis agora.", rent: "Para alugar", sale: "À venda", roommate: "Colega de casa", shortStay: "Estadia curta", month: "por mês", night: "por noite", year: "por ano", verified: "Anfitrião verificado", imageUnavailable: "Foto indisponível", previousPhoto: "Foto anterior", nextPhoto: "Foto seguinte", photo: "Foto", previousResults: "Imóveis anteriores", nextResults: "Imóveis seguintes" },
131
+ ar: { heading: "عقارات مختارة لك", selected: "اختارتها AIDA على Lojiz", result: "عقار", results: "عقارات", reason: "لماذا يناسبك", beds: "غرف", baths: "حمامات", guests: "ضيوف", photos: "صور", empty: "لا توجد عقارات عامة مطابقة متاحة حالياً.", rent: "للإيجار", sale: "للبيع", roommate: "سكن مشترك", shortStay: "إقامة قصيرة", month: "شهرياً", night: "لليلة", year: "سنوياً", verified: "مضيف موثق", imageUnavailable: "الصورة غير متاحة", previousPhoto: "الصورة السابقة", nextPhoto: "الصورة التالية", photo: "الصورة", previousResults: "العقارات السابقة", nextResults: "العقارات التالية" }
132
  };
133
+ var aidaNote = document.getElementById("aidaNote");
134
+ var cardsPrevious = document.getElementById("cardsPrevious");
135
+ var cardsNext = document.getElementById("cardsNext");
 
 
 
136
  var summary = document.getElementById("summary");
137
  var cards = document.getElementById("cards");
138
 
139
  function text(value) { return value == null ? "" : String(value); }
140
+ function element(tag, className, value) { var node = document.createElement(tag); if (className) node.className = className; if (value !== undefined) node.textContent = text(value); return node; }
141
+ function mapPinIcon() {
142
+ var namespace = "http://www.w3.org/2000/svg";
143
+ var icon = document.createElementNS(namespace, "svg");
144
+ icon.setAttribute("class", "location-icon lucide lucide-map-pin");
145
+ icon.setAttribute("viewBox", "0 0 24 24");
146
+ icon.setAttribute("aria-hidden", "true");
147
+ var path = document.createElementNS(namespace, "path");
148
+ path.setAttribute("d", "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0");
149
+ var circle = document.createElementNS(namespace, "circle");
150
+ circle.setAttribute("cx", "12"); circle.setAttribute("cy", "10"); circle.setAttribute("r", "3");
151
+ icon.appendChild(path); icon.appendChild(circle);
152
+ return icon;
153
  }
154
+ function imagePlaceholderIcon() {
155
+ var namespace = "http://www.w3.org/2000/svg";
156
+ var icon = document.createElementNS(namespace, "svg");
157
+ icon.setAttribute("class", "fallback-image-icon lucide lucide-image"); icon.setAttribute("viewBox", "0 0 24 24"); icon.setAttribute("aria-hidden", "true");
158
+ var rect = document.createElementNS(namespace, "rect"); rect.setAttribute("width", "18"); rect.setAttribute("height", "18"); rect.setAttribute("x", "3"); rect.setAttribute("y", "3"); rect.setAttribute("rx", "2");
159
+ var circle = document.createElementNS(namespace, "circle"); circle.setAttribute("cx", "9"); circle.setAttribute("cy", "9"); circle.setAttribute("r", "2");
160
+ var path = document.createElementNS(namespace, "path"); path.setAttribute("d", "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21");
161
+ icon.appendChild(rect); icon.appendChild(circle); icon.appendChild(path); return icon;
162
  }
163
+ function chevronIcon(direction) {
164
+ var namespace = "http://www.w3.org/2000/svg";
165
+ var icon = document.createElementNS(namespace, "svg"); icon.setAttribute("class", "lucide lucide-chevron-" + direction); icon.setAttribute("viewBox", "0 0 24 24"); icon.setAttribute("aria-hidden", "true");
166
+ var path = document.createElementNS(namespace, "path"); path.setAttribute("d", direction === "left" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6"); icon.appendChild(path); return icon;
167
  }
168
+ function normalizeLocale(value) { var code = text(value || navigator.language || "en").toLowerCase().split("-")[0]; return labels[code] ? code : "en"; }
169
+ function formatPrice(card, locale) { try { return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(Number(card.price || 0)) + " " + text(card.currency).toUpperCase(); } catch (_) { return text(card.currency) + " " + text(card.price); } }
170
+ function typeLabel(value, t) { var clean = text(value).toLowerCase().replace(/[-_ ]/g, ""); if (clean === "sale") return t.sale; if (clean.indexOf("short") >= 0) return t.shortStay; if (clean.indexOf("room") >= 0) return t.roommate; return t.rent; }
171
+ function periodLabel(value, listingType, t) { var clean = text(value).toLowerCase(); var type = text(listingType).toLowerCase(); if (clean.indexOf("year") >= 0) return t.year; if (clean.indexOf("night") >= 0 || type.indexOf("short") >= 0) return t.night; if (type === "sale") return ""; return t.month; }
172
+ function initials(value) { var parts = text(value).trim().split(/s+/).filter(Boolean); if (!parts.length) return "LJ"; return parts.slice(0, 2).map(function (part) { return part.charAt(0); }).join("").toUpperCase(); }
173
+ function addFact(container, value, label) { if (value === null || value === undefined || Number(value) === 0) return; container.appendChild(element("span", "fact", text(value) + " " + label)); }
174
+ function openLink(url) {
175
+ if (window.openai && typeof window.openai.openExternal === "function") {
176
+ try { window.openai.openExternal({ href: url }); return; } catch (_) {}
177
+ }
178
+ window.open(url, "_blank", "noopener,noreferrer");
179
+ }
180
+
181
+ cardsPrevious.appendChild(chevronIcon("left"));
182
+ cardsNext.appendChild(chevronIcon("right"));
183
+ function updateCardsNav() {
184
+ var maxScroll = Math.max(0, cards.scrollWidth - cards.clientWidth);
185
+ cardsPrevious.disabled = cards.scrollLeft <= 4;
186
+ cardsNext.disabled = cards.scrollLeft >= maxScroll - 4 || maxScroll <= 4;
187
  }
188
+ function scrollCards(direction) {
189
+ cards.scrollBy({ left: direction * Math.max(250, cards.clientWidth * .82), behavior: "smooth" });
190
+ }
191
+ cardsPrevious.addEventListener("click", function () { scrollCards(-1); });
192
+ cardsNext.addEventListener("click", function () { scrollCards(1); });
193
+ cards.addEventListener("scroll", updateCardsNav, { passive: true });
194
+ window.addEventListener("resize", updateCardsNav, { passive: true });
195
+ function cardNode(card, t, locale) {
196
  var article = element("article", "card");
197
+ article.tabIndex = 0;
198
+ article.setAttribute("role", "link");
199
+ article.setAttribute("aria-label", text(card.title) + " — Lojiz");
200
+ function openCard() { if (card.public_url) openLink(text(card.public_url)); }
201
+ article.addEventListener("click", openCard);
202
+ article.addEventListener("keydown", function (event) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openCard(); } });
203
+
204
  var media = element("div", "media");
205
+ var fallback = element("div", "media-fallback");
206
+ var fallbackContent = element("div", "fallback-content");
207
+ var fallbackMark = element("div", "fallback-mark");
208
+ fallbackMark.appendChild(imagePlaceholderIcon());
209
+ var fallbackLogo = document.createElement("img");
210
+ fallbackLogo.className = "fallback-logo"; fallbackLogo.src = logoDataUri; fallbackLogo.alt = "";
211
+ fallbackMark.appendChild(fallbackLogo); fallbackContent.appendChild(fallbackMark);
212
+ fallbackContent.appendChild(element("span", "fallback-label", t.imageUnavailable)); fallback.appendChild(fallbackContent);
213
  media.appendChild(fallback);
214
+
215
+ var sources = Array.isArray(card.images) ? card.images.filter(Boolean) : [];
216
+ var imageIndex = 0;
217
  var image = document.createElement("img");
218
+ image.className = "media-image";
219
  image.alt = text(card.title);
220
  image.loading = "lazy";
221
  image.referrerPolicy = "no-referrer";
222
+ image.addEventListener("load", function () { image.hidden = false; fallback.hidden = true; image.classList.remove("changing"); });
223
+ image.addEventListener("error", function () { image.hidden = true; fallback.hidden = false; });
 
224
  media.appendChild(image);
225
+ media.appendChild(element("div", "media-shade"));
226
+ media.appendChild(element("span", "type", typeLabel(card.listing_type, t)));
227
+
228
+
229
+ var dots = null;
230
+ var autoplayTimer = null;
231
+ function stopAutoplay() { if (autoplayTimer) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
232
+ function startAutoplay() {
233
+ stopAutoplay();
234
+ if (sources.length < 2 || window.matchMedia("(prefers-reduced-motion: reduce)").matches || document.hidden) return;
235
+ autoplayTimer = window.setInterval(function () { showImage(imageIndex + 1); }, 4200);
236
+ }
237
+ function showImage(nextIndex) {
238
+ if (!sources.length) { image.hidden = true; fallback.hidden = false; return; }
239
+ imageIndex = (nextIndex + sources.length) % sources.length;
240
+ image.hidden = false;
241
+ fallback.hidden = true;
242
+ image.classList.add("changing");
243
+ image.src = text(sources[imageIndex]);
244
+ if (dots) Array.from(dots.children).forEach(function (dot, index) { dot.classList.toggle("active", index === imageIndex); });
245
+ }
246
+ if (sources.length > 1) {
247
+ var previous = element("button", "carousel-button previous");
248
+ previous.appendChild(chevronIcon("left"));
249
+ previous.type = "button";
250
+ previous.setAttribute("aria-label", t.previousPhoto);
251
+ previous.addEventListener("click", function (event) { event.preventDefault(); event.stopPropagation(); showImage(imageIndex - 1); startAutoplay(); });
252
+ media.appendChild(previous);
253
+ var next = element("button", "carousel-button next");
254
+ next.appendChild(chevronIcon("right"));
255
+ next.type = "button";
256
+ next.setAttribute("aria-label", t.nextPhoto);
257
+ next.addEventListener("click", function (event) { event.preventDefault(); event.stopPropagation(); showImage(imageIndex + 1); startAutoplay(); });
258
+ media.appendChild(next);
259
+ dots = element("div", "dots");
260
+ sources.forEach(function (_, index) {
261
+ var dot = element("button", "dot" + (index === 0 ? " active" : ""));
262
+ dot.type = "button";
263
+ dot.setAttribute("aria-label", t.photo + " " + (index + 1));
264
+ dot.addEventListener("click", function (event) { event.preventDefault(); event.stopPropagation(); showImage(index); startAutoplay(); });
265
+ dots.appendChild(dot);
266
+ });
267
+ media.appendChild(dots);
268
+ }
269
+ showImage(0);
270
+ var pointerStartX = null;
271
+ media.addEventListener("pointerdown", function (event) { pointerStartX = event.clientX; stopAutoplay(); });
272
+ media.addEventListener("pointerup", function (event) { if (pointerStartX !== null) { var distance = event.clientX - pointerStartX; if (Math.abs(distance) > 38) showImage(imageIndex + (distance < 0 ? 1 : -1)); } pointerStartX = null; startAutoplay(); });
273
+ media.addEventListener("pointercancel", function () { pointerStartX = null; startAutoplay(); });
274
+ article.addEventListener("mouseenter", stopAutoplay);
275
+ article.addEventListener("mouseleave", startAutoplay);
276
+ article.addEventListener("focusin", stopAutoplay);
277
+ article.addEventListener("focusout", startAutoplay);
278
+ document.addEventListener("visibilitychange", function () { if (document.hidden) stopAutoplay(); else startAutoplay(); });
279
+ startAutoplay();
280
  article.appendChild(media);
281
 
282
+ var content = element("div", "content");
283
+ var titleRow = element("div", "title-row");
284
+ titleRow.appendChild(element("h2", "title", card.title));
285
+ if (Number(card.rating || 0) > 0) { var rating = element("span", "rating"); rating.appendChild(element("span", "rating-value", "★ " + Number(card.rating).toFixed(1))); titleRow.appendChild(rating); }
286
+ content.appendChild(titleRow);
287
+ var location = element("div", "location");
288
+ location.appendChild(mapPinIcon());
289
+ location.appendChild(element("span", "location-label", card.location));
290
+ content.appendChild(location);
291
  var facts = element("div", "facts");
292
  addFact(facts, card.bedrooms, t.beds);
293
  addFact(facts, card.bathrooms, t.baths);
294
  addFact(facts, card.max_guests, t.guests);
295
+ if (facts.childNodes.length) content.appendChild(facts);
296
+
297
+ var footer = element("div", "footer");
298
+ var priceWrap = element("div", "price-wrap");
299
+ var priceLine = element("div", "price-line");
300
+ priceLine.appendChild(element("div", "price", formatPrice(card, locale)));
301
+ if (card.host_verified) {
302
+ var verifiedPrice = document.createElement("img");
303
+ verifiedPrice.className = "verified-price"; verifiedPrice.src = verifiedDataUri; verifiedPrice.alt = t.verified;
304
+ verifiedPrice.title = t.verified;
305
+ priceLine.appendChild(verifiedPrice);
306
  }
307
+ priceWrap.appendChild(priceLine);
308
+ priceWrap.appendChild(element("div", "period", periodLabel(card.price_type, card.listing_type, t)));
309
+ footer.appendChild(priceWrap);
310
+ var host = element("span", "host");
311
+ host.title = text(card.host_name || "Lojiz host");
312
+ host.setAttribute("aria-label", text(card.host_name || "Lojiz host"));
313
+ var hostInner = element("span", "host-inner", initials(card.host_name));
314
+ if (card.host_avatar) { var avatar = document.createElement("img"); avatar.className = "host-image"; avatar.src = text(card.host_avatar); avatar.alt = text(card.host_name || "Host"); avatar.loading = "lazy"; avatar.referrerPolicy = "no-referrer"; avatar.addEventListener("error", function () { avatar.remove(); }); hostInner.appendChild(avatar); }
315
+ host.appendChild(hostInner);
316
+
317
+ footer.appendChild(host);
318
+ content.appendChild(footer);
319
+ article.appendChild(content);
320
  return article;
321
  }
322
+
323
  function render(data) {
324
  data = data && typeof data === "object" ? data : {};
325
+ var locale = normalizeLocale(data.language);
326
+ var t = labels[locale];
327
+ document.documentElement.lang = locale;
328
+ document.documentElement.dir = locale === "ar" ? "rtl" : "ltr";
329
  var rows = Array.isArray(data.listings) ? data.listings.map(function (listing) { return { listing: listing, strengths: [] }; }) : [];
330
  if (!rows.length && Array.isArray(data.items)) rows = data.items;
331
+
332
+
333
+
334
+ var aidaMessage = text(data.message).trim();
335
+ summary.textContent = aidaMessage;
336
+ aidaNote.hidden = !aidaMessage;
337
+ cardsPrevious.setAttribute("aria-label", t.previousResults);
338
+ cardsNext.setAttribute("aria-label", t.nextResults);
339
  cards.replaceChildren();
340
+ if (!rows.length) { cards.appendChild(element("div", "empty", t.empty)); updateCardsNav(); return; }
341
+ rows.forEach(function (row) { if (row && row.listing) cards.appendChild(cardNode(row.listing, t, locale)); });
342
+ window.setTimeout(updateCardsNav, 0);
343
  }
344
 
345
+ window.addEventListener("message", function (event) { if (event.source !== window.parent) return; var message = event.data; if (!message || message.jsonrpc !== "2.0") return; if (message.method === "ui/notifications/tool-result") render(message.params && message.params.structuredContent); }, { passive: true });
346
+ if (window.openai && window.openai.toolOutput) render(window.openai.toolOutput); else render(null);
 
 
 
 
 
 
 
347
  })();
348
  </script>
349
  </body>
integrations/lojiz_guest_mcp/web/lojiz-logo.png ADDED
integrations/lojiz_guest_mcp/web/preview.html ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Lojiz MCP card preview</title>
7
+ <style>
8
+ * { box-sizing: border-box; }
9
+ body { margin: 0; min-height: 100vh; background: #ecebf0; color: #201b27; font-family: Inter, system-ui, sans-serif; }
10
+ header { display: flex; align-items: center; justify-content: space-between; gap: 16px; max-width: 1040px; margin: 0 auto; padding: 20px 18px 12px; }
11
+ h1 { margin: 0; font-size: 18px; }
12
+ p { margin: 4px 0 0; color: #706979; font-size: 12px; }
13
+ .badge { border-radius: 999px; padding: 7px 10px; background: #fff; color: #8f489f; font-size: 11px; font-weight: 800; box-shadow: 0 3px 10px rgba(0,0,0,.06); }
14
+ main { max-width: 1040px; margin: 0 auto; padding: 18px; }
15
+ .frame { overflow: hidden; border: 1px solid rgba(29,24,36,.11); border-radius: 8px; background: #fff; box-shadow: 0 18px 60px rgba(30,23,38,.12); }
16
+ iframe { display: block; width: 100%; height: 610px; border: 0; }
17
+ @media (max-width: 560px) { main { padding: 0; } .frame { border-radius: 0; border-inline: 0; } iframe { height: 650px; } }
18
+ </style>
19
+ </head>
20
+ <body>
21
+
22
+ <main><div class="frame"><iframe id="widget" title="Lojiz property results"></iframe></div></main>
23
+ <script>
24
+ var fixture = {
25
+ success: true,
26
+ guest_mode: true,
27
+ powered_by: "AIDA on Lojiz",
28
+ provider: "chatgpt",
29
+ language: "en",
30
+ mode: "recommendation",
31
+ message: "I found three available homes in Cotonou that match your search.",
32
+ total: 3,
33
+ session_id: "preview",
34
+ listings: [
35
+ { public_id: "5Q5RQDPK76", title: "Furnished studio in the heart of Cotonou", summary: "", listing_type: "rent", price: 25000, currency: "XOF", price_type: "monthly", location: "Cotonou, Benin", bedrooms: 0, bathrooms: 1, max_guests: null, amenities: ["Furnished"], images: ["https://images.unsplash.com/photo-1522708323590-d24dbb6b0267e?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?auto=format&fit=crop&w=900&q=80"], rating: 4.8, reviews_count: 18, host_name: "Ebuka Destiny", host_avatar: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&q=80", host_verified: true, availability: "available", public_url: "https://aida.lojiz.com/listing/studio-meuble-a-louer-a-cotonou-5Q5RQDPK76", action_label: "View on Lojiz" },
36
+ { public_id: "J8QWXPNXPF", title: "New one-bedroom furnished apartment", summary: "", listing_type: "rent", price: 80000, currency: "XOF", price_type: "monthly", location: "Cotonou, Benin", bedrooms: 1, bathrooms: 2, max_guests: null, amenities: ["Furnished"], images: ["https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=900&q=80", "https://images.unsplash.com/photo-1560185008-b033106af5c3?auto=format&fit=crop&w=900&q=80"], rating: 4.6, reviews_count: 9, host_name: "Yom Sagbo", host_avatar: null, host_verified: true, availability: "available", public_url: "https://aida.lojiz.com/listing/appartement-1-chambre-a-louer-a-cotonou-cotonou-J8QWXPNXPF", action_label: "View on Lojiz" },
37
+ { public_id: "8XFBC5VGN7", title: "Spacious two-bedroom apartment in Cotonou", summary: "", listing_type: "rent", price: 110000, currency: "XOF", price_type: "monthly", location: "Cotonou, Benin", bedrooms: 2, bathrooms: 2, max_guests: null, amenities: ["Parking"], images: ["https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=900&q=80"], rating: 0, reviews_count: 0, host_name: "John Dossou", host_avatar: null, host_verified: false, availability: "available", public_url: "https://aida.lojiz.com/listing/appartement-2-chambres-a-louer-a-cotonou-8XFBC5VGN7", action_label: "View on Lojiz" }
38
+ ], search_params: { location: "Cotonou" }
39
+ };
40
+ function blobToDataUri(blob) {
41
+ return new Promise(function (resolve) { var reader = new FileReader(); reader.onload = function () { resolve(reader.result); }; reader.readAsDataURL(blob); });
42
+ }
43
+ Promise.all([
44
+ fetch("listing-cards.html").then(function (response) { return response.text(); }),
45
+ fetch("lojiz-logo.png").then(function (response) { return response.blob(); }).then(blobToDataUri),
46
+ fetch("verified-badge.png").then(function (response) { return response.blob(); }).then(blobToDataUri)
47
+ ]).then(function (values) {
48
+ var frame = document.getElementById("widget");
49
+ frame.srcdoc = values[0]
50
+ .split("__LOJIZ_LOGO_DATA_URI__").join(values[1])
51
+ .split("__LOJIZ_VERIFIED_DATA_URI__").join(values[2]);
52
+ frame.onload = function () { frame.contentWindow.postMessage({ jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { structuredContent: fixture } }, "*"); };
53
+ }); </script>
54
+ </body>
55
+ </html>
integrations/lojiz_guest_mcp/web/verified-badge.png ADDED
tests/test_guest_discovery_contract.py CHANGED
@@ -28,8 +28,9 @@ def _card(**overrides) -> GuestListingCard:
28
  "images": ["https://cdn.example.com/one.jpg"],
29
  "rating": 4.5,
30
  "reviews_count": 12,
 
 
31
  "host_verified": True,
32
- "match_reason": "AIDA ranked it for the requested location.",
33
  "public_url": "https://aida.lojiz.com/listing/two-bedroom-A7K92XQ4ZZ",
34
  "action_label": "View on Lojiz",
35
  }
@@ -50,6 +51,9 @@ def test_public_contract_ignores_private_listing_fields() -> None:
50
  claim_token="secret",
51
  )
52
  payload = card.model_dump()
 
 
 
53
  assert "owner_whatsapp" not in payload
54
  assert "claim_token" not in payload
55
 
 
28
  "images": ["https://cdn.example.com/one.jpg"],
29
  "rating": 4.5,
30
  "reviews_count": 12,
31
+ "host_name": "Ebuka Destiny",
32
+ "host_avatar": "https://cdn.example.com/host.jpg",
33
  "host_verified": True,
 
34
  "public_url": "https://aida.lojiz.com/listing/two-bedroom-A7K92XQ4ZZ",
35
  "action_label": "View on Lojiz",
36
  }
 
51
  claim_token="secret",
52
  )
53
  payload = card.model_dump()
54
+ assert payload["host_name"] == "Ebuka Destiny"
55
+ assert payload["host_avatar"] == "https://cdn.example.com/host.jpg"
56
+ assert "match_reason" not in payload
57
  assert "owner_whatsapp" not in payload
58
  assert "claim_token" not in payload
59
 
tests/test_guest_mcp_contract.py CHANGED
@@ -38,7 +38,18 @@ async def test_guest_mcp_exposes_listing_card_resource():
38
 
39
  contents = list(await lojiz_guest_mcp.read_resource(TEMPLATE_URI))
40
  assert contents
41
- assert "View on Lojiz" in str(contents[0].content)
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  @pytest.mark.asyncio
44
  async def test_guest_mcp_streamable_http_initializes():
 
38
 
39
  contents = list(await lojiz_guest_mcp.read_resource(TEMPLATE_URI))
40
  assert contents
41
+ html = str(contents[0].content)
42
+ assert 'class="aida-note-logo"' in html
43
+ assert ".host {" in html
44
+ assert 'element("span", "host")' in html
45
+ assert 'class="cta"' not in html
46
+ assert "carousel-button" in html
47
+ assert "match_reason" not in html
48
+ assert "__LOJIZ_LOGO_DATA_URI__" not in html
49
+ assert "__LOJIZ_VERIFIED_DATA_URI__" not in html
50
+ assert "photo-count" not in html
51
+ assert "lucide-map-pin" in html
52
+ assert "startAutoplay" in html
53
 
54
  @pytest.mark.asyncio
55
  async def test_guest_mcp_streamable_http_initializes():