Spaces:
Running
Running
Commit ·
45df327
1
Parent(s): d35fb02
fyp
Browse files- app/__pycache__/config.cpython-313.pyc +0 -0
- app/__pycache__/database.cpython-313.pyc +0 -0
- app/ai/services/__pycache__/search_service.cpython-313.pyc +0 -0
- app/integrations/guest_mcp.py +16 -1
- app/models/__pycache__/listing.cpython-313.pyc +0 -0
- app/schemas/guest_discovery.py +4 -1
- app/services/guest_discovery_service.py +5 -73
- integrations/lojiz_guest_mcp/src/server.ts +14 -3
- integrations/lojiz_guest_mcp/web/listing-cards.html +290 -113
- integrations/lojiz_guest_mcp/web/lojiz-logo.png +0 -0
- integrations/lojiz_guest_mcp/web/preview.html +55 -0
- integrations/lojiz_guest_mcp/web/verified-badge.png +0 -0
- tests/test_guest_discovery_contract.py +5 -1
- tests/test_guest_mcp_contract.py +12 -1
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
--
|
| 10 |
-
--surface: #
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
--pink: #
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
* { box-sizing: border-box; }
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
.
|
|
|
|
| 25 |
.brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
| 26 |
-
.
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
.
|
| 30 |
-
.
|
| 31 |
-
.
|
| 32 |
-
.
|
| 33 |
-
.
|
| 34 |
-
.
|
| 35 |
-
.
|
| 36 |
-
.
|
| 37 |
-
.
|
| 38 |
-
.
|
| 39 |
-
.
|
| 40 |
-
.
|
| 41 |
-
.
|
| 42 |
-
.
|
| 43 |
-
.
|
| 44 |
-
.
|
| 45 |
-
.
|
| 46 |
-
.
|
| 47 |
-
.
|
| 48 |
-
.
|
| 49 |
-
.
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
</style>
|
| 54 |
</head>
|
| 55 |
<body>
|
| 56 |
<main class="shell">
|
| 57 |
-
<
|
| 58 |
-
<
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
<
|
|
|
|
|
|
|
| 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",
|
| 71 |
-
fr: { heading: "Des biens sélectionnés pour vous", result: "
|
| 72 |
-
es: { heading: "Propiedades elegidas para ti", result: "
|
| 73 |
-
pt: { heading: "Imóveis escolhidos para si", result: "
|
| 74 |
-
ar: { heading: "عقارات مختارة لك",
|
| 75 |
};
|
| 76 |
-
var
|
| 77 |
-
|
| 78 |
-
var
|
| 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 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
-
function
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
-
function
|
| 97 |
-
var
|
| 98 |
-
|
|
|
|
| 99 |
}
|
| 100 |
-
function
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
}
|
| 104 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
var article = element("article", "card");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
var media = element("div", "media");
|
| 107 |
-
var fallback = element("div", "media-fallback"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 115 |
-
if (Array.isArray(card.images) && card.images[0]) image.src = text(card.images[0]);
|
| 116 |
media.appendChild(image);
|
| 117 |
-
media.appendChild(element("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
article.appendChild(media);
|
| 119 |
|
| 120 |
-
var
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
var
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
| 139 |
}
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 159 |
}
|
| 160 |
|
| 161 |
-
window.addEventListener("message", function (event) {
|
| 162 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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():
|