/* Customer app — orange #FF6A1A design system, Plus Jakarta Sans + Noto Sans Thai.
Replaces the earlier navy/espresso Customer prototype per the new
"Three-app system" design handoff (Customer App.dc.html + tokens.md). */
const { useState, useEffect } = React;
/** Meta Pixel event helper — the base pixel script lives in the HTML shell (SVLM Customer Prototype.html), so `fbq` may not exist yet on first paint or if a content blocker stripped it; never let tracking break a real user action. */
function trackPixel(event, params) {
try { if (typeof window.fbq === 'function') window.fbq('track', event, params); } catch (e) { /* ad blockers etc. */ }
}
/* ---------------- Bilingual copy dictionary ---------------- */
const CUST_DICT = {
newsletter_title: { en: 'Get new listings by email', th: 'รับข่าวทรัพย์ใหม่ทางอีเมล' },
newsletter_subtitle: { en: 'New properties and promotions, straight to your inbox.', th: 'ทรัพย์ใหม่และโปรโมชั่น ส่งตรงถึงอีเมลของคุณ' },
newsletter_email_ph: { en: 'Email address', th: 'อีเมล' },
newsletter_name_ph: { en: 'Name (optional)', th: 'ชื่อ (ถ้ามี)' },
newsletter_consent: { en: 'I agree to receive marketing emails from SVLM. I can unsubscribe anytime.', th: 'ฉันยินยอมรับอีเมลการตลาดจาก SVLM และสามารถยกเลิกได้ทุกเมื่อ' },
newsletter_submit: { en: 'Subscribe', th: 'สมัครรับข่าวสาร' },
newsletter_submitting: { en: 'Submitting…', th: 'กำลังส่ง…' },
newsletter_subscribed: { en: 'Subscribed ✓ Thank you!', th: 'สมัครสำเร็จ ✓ ขอบคุณค่ะ/ครับ' },
newsletter_failed: { en: 'Something went wrong. Please try again.', th: 'เกิดข้อผิดพลาด กรุณาลองอีกครั้ง' },
login_tagline: { en: 'Buy, rent, and manage every property need in one place.', th: 'ซื้อ เช่า และจัดการทุกเรื่องอสังหาฯ ในที่เดียว' },
login_welcome_title: { en: 'Welcome to SVLM', th: 'ยินดีต้อนรับสู่ SVLM' },
login_welcome_tagline: { en: 'Sign in to browse and manage your properties', th: 'เข้าสู่ระบบเพื่อค้นหาและจัดการทรัพย์ของคุณ' },
login_social_facebook: { en: 'Sign in with Facebook', th: 'เข้าสู่ระบบด้วย Facebook' },
login_social_google: { en: 'Sign in with Google', th: 'เข้าสู่ระบบด้วย Google' },
login_social_line: { en: 'Sign in with LINE', th: 'เข้าสู่ระบบด้วย LINE' },
login_social_apple: { en: 'Sign in with Apple', th: 'เข้าสู่ระบบด้วย Apple' },
login_social_email: { en: 'Sign in with email or phone', th: 'เข้าสู่ระบบด้วยอีเมลหรือเบอร์โทร' },
login_coming_soon: { en: 'Coming soon', th: 'เร็วๆ นี้' },
login_back: { en: 'Back', th: 'ย้อนกลับ' },
login_terms_prefix: { en: 'I have read and agreed to the', th: 'ฉันได้อ่านและยอมรับ' },
login_terms_service: { en: 'Service Agreement', th: 'ข้อตกลงการใช้บริการ' },
login_terms_and: { en: 'and', th: 'และ' },
login_terms_privacy: { en: 'Privacy Policy', th: 'นโยบายความเป็นส่วนตัว' },
login_signin_title: { en: 'Sign in with email or phone', th: 'เข้าสู่ระบบด้วยอีเมลหรือเบอร์โทร' },
login_mobile_or_email_ph: { en: 'Enter mobile No. or email', th: 'กรอกเบอร์โทรหรืออีเมล' },
login_password_ph2: { en: 'Enter password', th: 'กรอกรหัสผ่าน' },
login_remember: { en: 'Remember password', th: 'จดจำรหัสผ่าน' },
login_log_in_btn: { en: 'Log in', th: 'เข้าสู่ระบบ' },
login_forgot_password: { en: 'Forgot password', th: 'ลืมรหัสผ่าน' },
login_signup_title: { en: 'Sign up', th: 'สมัครสมาชิก' },
login_get_code: { en: 'Get a code', th: 'ขอรหัส' },
login_next_btn: { en: 'Next', th: 'ถัดไป' },
login_otp_help: { en: 'Not receiving the verification code?', th: 'ไม่ได้รับรหัสยืนยัน?' },
login_switch_methods: { en: 'Sign in with password instead', th: 'เข้าสู่ระบบด้วยรหัสผ่านแทน' },
terms_modal_title: { en: 'Terms & Privacy Notice', th: 'ข้อกำหนดและความเป็นส่วนตัว' },
terms_modal_body: { en: 'To better protect your rights, please read the following terms before continuing.', th: 'เพื่อปกป้องสิทธิ์ของท่าน กรุณาอ่านข้อกำหนดต่อไปนี้ก่อนดำเนินการต่อ' },
terms_modal_cancel: { en: 'Cancel', th: 'ยกเลิก' },
terms_modal_agree: { en: 'Agree', th: 'ยอมรับ' },
login_phone_label: { en: 'Phone number', th: 'หมายเลขโทรศัพท์' },
login_phone_ph: { en: '081-234-5678', th: '081-234-5678' },
login_continue: { en: 'Continue', th: 'ดำเนินการต่อ' },
login_or: { en: 'or continue with', th: 'หรือดำเนินการต่อด้วย' },
login_terms: { en: 'By continuing you agree to SVLM Terms & Privacy Policy.', th: 'การดำเนินการต่อถือว่าคุณยอมรับข้อกำหนดและนโยบายความเป็นส่วนตัวของ SVLM' },
login_code_label: { en: 'Verification code', th: 'รหัสยืนยัน' },
login_code_ph: { en: '123456', th: '123456' },
login_verify: { en: 'Verify', th: 'ยืนยัน' },
login_error: { en: 'Something went wrong. Please try again.', th: 'เกิดข้อผิดพลาด กรุณาลองใหม่' },
login_change_phone: { en: 'Change phone number', th: 'เปลี่ยนหมายเลขโทรศัพท์' },
login_tab_otp: { en: 'OTP', th: 'OTP' },
login_tab_password: { en: 'Password', th: 'รหัสผ่าน' },
login_name_label: { en: 'Full name', th: 'ชื่อ-นามสกุล' },
login_name_ph: { en: 'Somchai Jaidee', th: 'สมชาย ใจดี' },
login_password_label: { en: 'Password', th: 'รหัสผ่าน' },
login_password_ph: { en: 'At least 8 characters', th: 'อย่างน้อย 8 ตัวอักษร' },
login_email_label: { en: 'Email (optional)', th: 'อีเมล (ไม่บังคับ)' },
login_email_ph: { en: 'you@example.com', th: 'you@example.com' },
login_password_login_btn: { en: 'Log in', th: 'เข้าสู่ระบบ' },
login_password_register_btn: { en: 'Create account', th: 'สร้างบัญชี' },
login_no_account: { en: "Don't have an account? ", th: 'ยังไม่มีบัญชี? ' },
login_have_account: { en: 'Already have an account? ', th: 'มีบัญชีแล้ว? ' },
login_register_link: { en: 'Register', th: 'สมัครสมาชิก' },
login_login_link: { en: 'Log in', th: 'เข้าสู่ระบบ' },
greeting: { en: 'Good morning', th: 'สวัสดีตอนเช้า' },
search_ph: { en: 'Search location, project, or keyword', th: 'ค้นหาทำเล โครงการ หรือคำค้น' },
sec_hotdeals: { en: 'Hot Deals', th: 'ดีลร้อนแรง' },
sec_recommend: { en: 'Recommend', th: 'แนะนำ' },
sec_nearby: { en: 'Near By Me Listing', th: 'ทรัพย์ใกล้คุณ' },
map_no_positions: { en: 'No listings with a map location to show yet.', th: 'ยังไม่มีทรัพย์ที่ระบุตำแหน่งบนแผนที่' },
home_hero_title: { en: 'Sale and Rent Condo, House, Land', th: 'ซื้อ ขาย เช่า คอนโด บ้าน ที่ดิน' },
home_hero_welcome: { en: 'Welcome to SVLM Thailand', th: 'ยินดีต้อนรับสู่ SVLM ประเทศไทย' },
sec_prime_location: { en: 'Prime Location', th: 'ทำเลไพรม์' },
sec_recommended: { en: 'Recommended for you', th: 'แนะนำสำหรับคุณ' },
sec_shortterm: { en: 'Short-term Stays', th: 'ที่พักเช่ารายวัน' },
mode_buy: { en: 'Buy', th: 'ซื้อ' },
mode_rent: { en: 'Rent', th: 'เช่า' },
mode_shortterm: { en: 'Short-term', th: 'รายวัน' },
shortterm_title: { en: 'Short-term Leasing', th: 'เช่าพักระยะสั้น' },
shortterm_sub: { en: 'Flexible 1, 3 or 6-month leases — book online with a deposit', th: 'สัญญาเช่ายืดหยุ่น 1, 3 หรือ 6 เดือน — จองออนไลน์พร้อมมัดจำ' },
shortterm_empty: { en: 'No short-term rentals available right now.', th: 'ยังไม่มีห้องเช่าระยะสั้นในขณะนี้' },
night_label: { en: '/night', th: '/คืน' },
see_all: { en: 'See all', th: 'ดูทั้งหมด' },
search_title: { en: 'Search', th: 'ค้นหา' },
search_list: { en: 'List', th: 'รายการ' },
search_map: { en: 'Map', th: 'แผนที่' },
filter_price: { en: 'Price', th: 'ราคา' },
filter_beds: { en: 'Beds', th: 'ห้องนอน' },
filter_type: { en: 'Type', th: 'ประเภท' },
filter_more: { en: 'More', th: 'เพิ่มเติม' },
filter_location: { en: 'Location', th: 'ทำเล' },
live_status: { en: 'Live', th: 'อัปเดตสด' },
load_more: { en: 'Load more', th: 'โหลดเพิ่มเติม' },
loading_more: { en: 'Loading…', th: 'กำลังโหลด…' },
results_count: { en: '{shown} of {total} properties', th: 'แสดง {shown} จาก {total} รายการ' },
featured_badge: { en: 'Featured Property', th: 'ทรัพย์เด่นประจำสัปดาห์' },
province_title: { en: 'Select Province', th: 'เลือกจังหวัด' },
province_search_ph: { en: 'Search provinces...', th: 'ค้นหาจังหวัด...' },
province_popular: { en: 'Popular Destinations', th: 'จุดหมายยอดนิยม' },
province_all: { en: 'All 77 Provinces', th: 'ทั้ง 77 จังหวัด' },
list_province_label: { en: 'Province', th: 'จังหวัด' },
list_province_ph: { en: 'Select province', th: 'เลือกจังหวัด' },
detail_overview: { en: 'Overview', th: 'ภาพรวม' },
detail_floorplan: { en: 'Floor Plan', th: 'แผนผัง' },
detail_location: { en: 'Location', th: 'ตำแหน่ง' },
detail_chat: { en: 'Chat', th: 'แชท' },
coagent_sub: { en: 'Already working with another agent? Let us know so they get credit for this deal.', th: 'กำลังทำงานกับเอเจนต์ท่านอื่นอยู่ใช่ไหม แจ้งให้เราทราบเพื่อให้เครดิตกับเอเจนต์ท่านนั้น' },
coagent_section_title: { en: 'Co-Agent Info', th: 'ข้อมูล Co-Agent' },
coagent_name_label: { en: 'Agent name', th: 'ชื่อ-นามสกุล Agent' },
coagent_email_label: { en: 'Email', th: 'อีเมล' },
coagent_phone_label: { en: 'Mobile number', th: 'เบอร์โทรศัพท์' },
coagent_message_label: { en: 'Message (optional)', th: 'ข้อความเพิ่มเติม (ถ้ามี)' },
inquire_duration_label: { en: 'Contract duration', th: 'ระยะเวลาสัญญา' },
inquire_duration_ph: { en: 'e.g. 1 year', th: 'เช่น 1 ปี' },
inquire_nationality_label: { en: 'Nationality', th: 'สัญชาติ' },
inquire_visa_type_label: { en: 'Visa type (optional)', th: 'ประเภทวีซ่า (ถ้ามี)' },
inquire_budget_label: { en: 'Budget', th: 'งบประมาณ' },
inquire_move_in_label: { en: 'Move-in date', th: 'วันที่ต้องการเข้าอยู่' },
inquire_has_pets_label: { en: 'I have pets', th: 'มีสัตว์เลี้ยง' },
inquire_pet_details_ph: { en: 'Type and number of pets', th: 'ชนิดและจำนวนสัตว์เลี้ยง' },
inquire_submitting: { en: 'Submitting…', th: 'กำลังส่ง…' },
inquire_success: { en: 'Inquiry sent! An agent will contact you soon.', th: 'ส่งคำถามแล้ว! เอเจนต์จะติดต่อกลับเร็วๆ นี้' },
inquire_error: { en: 'Something went wrong. Please try again.', th: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง' },
pub_inquiry_heading: { en: 'Interested in this property?', th: 'สนใจทรัพย์นี้?' },
pub_inquiry_next: { en: 'Next', th: 'ถัดไป' },
pub_inquiry_back: { en: 'Back', th: 'ย้อนกลับ' },
pub_inquiry_more_title: { en: 'Tell us a bit more', th: 'บอกเราเพิ่มเติม' },
pub_inquiry_consent_prefix: { en: 'By submitting, you agree to the', th: 'การส่งข้อมูลถือว่าท่านยอมรับ' },
pub_inquiry_tab_details: { en: 'Request Details', th: 'ขอรายละเอียด' },
pub_inquiry_tab_viewing: { en: 'Schedule Viewing', th: 'นัดดูสถานที่' },
pub_inquiry_name_label: { en: 'Name', th: 'ชื่อ' },
pub_inquiry_phone_label: { en: 'Phone', th: 'เบอร์โทร' },
pub_inquiry_email_label: { en: 'Email', th: 'อีเมล' },
pub_inquiry_viewing_date_label: { en: 'Preferred date', th: 'วันที่ต้องการ' },
pub_inquiry_viewing_time_label: { en: 'Preferred time', th: 'เวลาที่ต้องการ' },
pub_inquiry_type_label: { en: 'I am a...', th: 'ฉันคือ...' },
pub_inquiry_type_personal: { en: 'Personal Interest', th: 'สนใจส่วนตัว' },
pub_inquiry_type_coagent: { en: 'Co-Agent', th: 'โคเอเจนต์' },
pub_inquiry_language_label: { en: 'Preferred language', th: 'ภาษาที่ต้องการ' },
pub_inquiry_message_label: { en: 'Message', th: 'ข้อความ' },
pub_inquiry_default_message: { en: 'I am interested in this property at {property}. Please send me more details.', th: 'ฉันสนใจทรัพย์นี้ที่ {property} กรุณาส่งรายละเอียดเพิ่มเติมให้ฉัน' },
pub_inquiry_submit_details: { en: 'Request Details', th: 'ขอรายละเอียด' },
pub_inquiry_submit_viewing: { en: 'Schedule Viewing', th: 'นัดดูสถานที่' },
inquire_occupation_label: { en: 'Occupation', th: 'อาชีพ' },
inquire_job_position_label: { en: 'Position', th: 'ตำแหน่งงาน' },
inquire_living_members_label: { en: 'Number of occupants', th: 'จำนวนผู้อยู่อาศัย' },
inquire_gender_label: { en: 'Gender', th: 'เพศ' },
inquire_gender_male: { en: 'Male', th: 'ชาย' },
inquire_gender_female: { en: 'Female', th: 'หญิง' },
inquire_gender_other: { en: 'Other', th: 'อื่นๆ' },
inquire_parking_label: { en: 'Need a parking space', th: 'ต้องการที่จอดรถ' },
inquire_smoking_label: { en: 'Smoker', th: 'สูบบุหรี่' },
inquire_pet_friendly_label: { en: 'Looking for pet-friendly', th: 'ต้องการที่รับสัตว์เลี้ยง' },
// New Project Reviews (svlm_customer_app_project_review_spec.csv)
sec_project_reviews: { en: 'New Project Reviews', th: 'Review โครงการใหม่' },
pr_completion_year: { en: 'Completion Year', th: 'สร้างเสร็จปีไหน' },
pr_starting_price: { en: 'Starting Price', th: 'ราคาเริ่มต้น' },
pr_floors: { en: 'Floors', th: 'จำนวนชั้น' },
pr_total_units: { en: 'Total Units', th: 'จำนวนยูนิต' },
pr_unit_types: { en: 'Unit Configurations', th: 'ข้อมูลห้องชุด' },
pr_size_sqm: { en: 'Size (sqm)', th: 'ขนาด (ตร.ม.)' },
pr_price_per_sqm: { en: 'Starting from', th: 'เริ่มต้น' },
// Home screen sections below "Prime Location" — sample/placeholder content
// until real developer, province-stats, and blog data sources exist.
sec_new_projects: { en: 'New Projects', th: 'โครงการใหม่' },
new_projects_tag: { en: 'New Projects', th: 'โครงการใหม่' },
sec_recommend_developers: { en: 'Recommend Developers', th: 'แนะนำผู้พัฒนาโครงการ' },
search_province_eyebrow: { en: 'Search by province', th: 'ค้นหาตามจังหวัด' },
search_province_title: { en: 'Top provinces across Thailand', th: 'จังหวัดยอดนิยมทั่วประเทศไทย' },
top_locations_prefix: { en: 'Top Locations in', th: 'ทำเลยอดนิยมใน' },
top_locations_sub_prefix: { en: 'Listings of every property type across', th: 'รวมประกาศอสังหาริมทรัพย์หลากหลายประเภทในทำเลยอดนิยมทั่ว' },
top_locations_sub_suffix: { en: '’s most popular areas.', th: '' },
sec_blog_review: { en: 'Blog and Review', th: 'บทความและรีวิว' },
blog_review_sub: { en: 'Reviews and articles about every zone and project', th: 'รีวิวและบทความเกี่ยวกับทุกทำเลและโครงการ' },
discover_more_articles: { en: 'Discover more articles', th: 'บทความอื่น ๆ' },
pr_location: { en: 'Location & Accessibility', th: 'ทำเลและการเดินทาง' },
pr_facilities: { en: 'Facilities', th: 'สิ่งอำนวยความสะดวก' },
pr_promotions: { en: 'Current Promotions', th: 'โปรโมชั่นพิเศษ' },
pr_contact_sales: { en: 'ติดต่อขอราคา Sales SVLM', th: 'ติดต่อขอราคา Sales SVLM' },
pr_lead_title: { en: 'Tell us what you need', th: 'บอกความต้องการของคุณ' },
pr_lead_budget: { en: 'งบประมาณที่ตั้งไว้', th: 'งบประมาณที่ตั้งไว้' },
pr_lead_unit_size: { en: 'ขนาดที่ต้องการ', th: 'ขนาดที่ต้องการ' },
pr_lead_payment: { en: 'ต้องการกู้สินเชื่อธนาคาร หรือ ซื้อเงินสด', th: 'ต้องการกู้สินเชื่อธนาคาร หรือ ซื้อเงินสด' },
pr_lead_payment_loan: { en: 'Bank Loan', th: 'กู้สินเชื่อธนาคาร' },
pr_lead_payment_cash: { en: 'Cash', th: 'เงินสด' },
pr_lead_purpose: { en: 'วัตถุประสงค์', th: 'วัตถุประสงค์' },
pr_lead_purpose_self: { en: 'อยู่เอง', th: 'อยู่เอง' },
pr_lead_purpose_invest: { en: 'เพื่อการลงทุน', th: 'เพื่อการลงทุน' },
pr_lead_timeline: { en: 'ระยะเวลาตัดสินใจ', th: 'ระยะเวลาตัดสินใจ' },
pr_lead_timeline_now: { en: 'ทันที', th: 'ทันที' },
pr_lead_timeline_1_3: { en: '1-3 เดือน', th: '1-3 เดือน' },
pr_lead_timeline_3_6: { en: '3-6 เดือน', th: '3-6 เดือน' },
pr_lead_timeline_6plus: { en: 'มากกว่า 6 เดือน', th: 'มากกว่า 6 เดือน' },
pr_lead_first_home: { en: 'เป็นบ้านหลังแรกหรือไม่', th: 'เป็นบ้านหลังแรกหรือไม่' },
pr_lead_first_home_yes: { en: 'ใช่', th: 'ใช่' },
pr_lead_first_home_no: { en: 'ไม่ใช่', th: 'ไม่ใช่' },
pr_lead_occupation: { en: 'อาชีพ / ประเภทธุรกิจ', th: 'อาชีพ / ประเภทธุรกิจ' },
pr_lead_job_position: { en: 'ตำแหน่งงาน', th: 'ตำแหน่งงาน' },
pr_lead_income: { en: 'ฐานเงินเดือนช่วงประมาณเท่าไร', th: 'ฐานเงินเดือนช่วงประมาณเท่าไร' },
pr_lead_age: { en: 'อายุช่วงประมาณเท่าไร', th: 'อายุช่วงประมาณเท่าไร' },
pr_lead_submit: { en: 'ส่งข้อมูลให้ทีมขายติดต่อกลับ', th: 'ส่งข้อมูลให้ทีมขายติดต่อกลับ' },
pr_lead_submitting: { en: 'Submitting…', th: 'กำลังส่งข้อมูล…' },
pr_lead_success: { en: 'Thank you! Our SVLM sales team will contact you shortly.', th: 'ขอบคุณค่ะ/ครับ ทีมขาย SVLM จะติดต่อกลับหาคุณเร็วๆ นี้' },
pr_lead_error: { en: 'Something went wrong. Please try again.', th: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง' },
pr_lead_login_required: { en: 'Please sign in to send this request.', th: 'กรุณาเข้าสู่ระบบเพื่อส่งข้อมูล' },
detail_monthly_rates_title: { en: 'Monthly rates by lease term', th: 'ราคาต่อเดือนตามระยะเวลาเช่า' },
detail_monthly_rates_mo: { en: 'mo', th: 'ด.' },
detail_installment: { en: 'Est. monthly installment', th: 'ยอดผ่อนต่อเดือน (ประมาณ)' },
detail_min_income: { en: 'Min. monthly income required', th: 'รายได้ขั้นต่ำต่อเดือนที่ต้องมี' },
detail_far: { en: 'FAR (Floor Area Ratio)', th: 'FAR (อัตราส่วนพื้นที่อาคาร)' },
detail_max_buildable: { en: 'Max buildable area', th: 'พื้นที่ก่อสร้างสูงสุด' },
detail_full_calculator: { en: 'Open full mortgage calculator →', th: 'เปิดเครื่องคำนวณสินเชื่อแบบเต็ม →' },
calc_title: { en: 'Calculators', th: 'เครื่องคำนวณ' },
calc_tab_mortgage: { en: 'Mortgage', th: 'สินเชื่อ' },
calc_tab_transfer: { en: 'Transfer Tax', th: 'ค่าโอน' },
calc_mortgage_loan: { en: 'Loan amount (THB)', th: 'วงเงินกู้ (บาท)' },
calc_mortgage_rate: { en: 'Interest rate (% per year)', th: 'อัตราดอกเบี้ย (% ต่อปี)' },
calc_mortgage_term: { en: 'Loan term (years)', th: 'ระยะเวลากู้ (ปี)' },
calc_mortgage_method: { en: 'Interest method', th: 'วิธีคิดดอกเบี้ย' },
calc_method_effective: { en: 'Effective (decreasing)', th: 'ลดต้นลดดอก' },
calc_method_flat: { en: 'Flat', th: 'คงที่' },
calc_mortgage_income: { en: 'Monthly income (optional)', th: 'รายได้ต่อเดือน (ไม่บังคับ)' },
calc_mortgage_debt: { en: 'Existing monthly debt (optional)', th: 'ภาระหนี้ต่อเดือน (ไม่บังคับ)' },
calc_dsr: { en: 'DSR limit', th: 'เพดาน DSR' },
calc_calculate: { en: 'Calculate', th: 'คำนวณ' },
calc_calculating: { en: 'Calculating…', th: 'กำลังคำนวณ…' },
calc_monthly_payment: { en: 'Monthly payment', th: 'ยอดผ่อนต่อเดือน' },
calc_total_interest: { en: 'Total interest', th: 'ดอกเบี้ยรวม' },
calc_total_payment: { en: 'Total repayment', th: 'ยอดชำระรวม' },
calc_quick_estimate: { en: 'Quick estimate range', th: 'ช่วงประมาณการอย่างเร็ว' },
calc_eligible_yes: { en: 'Within your DSR limit', th: 'อยู่ในเพดาน DSR ของคุณ' },
calc_eligible_no: { en: 'Exceeds your DSR limit', th: 'เกินเพดาน DSR ของคุณ' },
calc_max_loan: { en: 'Max loan you likely qualify for', th: 'วงเงินกู้สูงสุดที่น่าจะได้รับอนุมัติ' },
calc_transfer_appraised: { en: 'Treasury appraised value (THB)', th: 'ราคาประเมินกรมธนารักษ์ (บาท)' },
calc_transfer_appraised_link: { en: 'Look up appraised value →', th: 'ตรวจสอบราคาประเมิน →' },
calc_transfer_sale_price: { en: 'Contract / sale price (THB)', th: 'ราคาซื้อขายตามสัญญา (บาท)' },
calc_transfer_property_type: { en: 'Property type', th: 'ประเภททรัพย์' },
calc_type_first_hand: { en: 'First-hand (from developer)', th: 'มือหนึ่ง (จากโครงการ)' },
calc_type_second_hand: { en: 'Second-hand', th: 'มือสอง' },
calc_transfer_seller_type: { en: 'Seller type', th: 'ประเภทผู้ขาย' },
calc_seller_individual: { en: 'Individual', th: 'บุคคลธรรมดา' },
calc_seller_company: { en: 'Company', th: 'นิติบุคคล' },
calc_transfer_years_owned: { en: 'Years owned', th: 'จำนวนปีที่ถือครอง' },
calc_transfer_registered: { en: 'House registered here 1+ year', th: 'มีทะเบียนบ้านที่นี่เกิน 1 ปี' },
calc_transfer_loan_amount: { en: 'Loan amount, if financing (optional)', th: 'วงเงินกู้ ถ้ามีการจำนอง (ไม่บังคับ)' },
calc_fee_transfer: { en: 'Transfer fee (2%)', th: 'ค่าธรรมเนียมโอน (2%)' },
calc_fee_sbt: { en: 'Specific Business Tax', th: 'ภาษีธุรกิจเฉพาะ' },
calc_fee_stamp: { en: 'Stamp duty', th: 'อากรแสตมป์' },
calc_fee_wht: { en: 'Withholding tax', th: 'ภาษีหัก ณ ที่จ่าย' },
calc_fee_mortgage: { en: 'Mortgage registration fee (1%)', th: 'ค่าจดจำนอง (1%)' },
calc_fee_total: { en: 'Total transfer costs', th: 'ค่าใช้จ่ายรวมในการโอน' },
insurance_title: { en: 'Insurance Quote Request', th: 'ขอใบเสนอราคาประกันภัย' },
insurance_subtitle: { en: 'Home & condo insurance from ฿990/year — our team will follow up with a quote.', th: 'ประกันบ้านและคอนโดเริ่มต้น ฿990/ปี — ทีมงานจะติดต่อกลับพร้อมใบเสนอราคา' },
insurance_coverage: { en: 'Coverage type', th: 'ประเภทความคุ้มครอง' },
insurance_coverage_building: { en: 'Building', th: 'ตัวอาคาร' },
insurance_coverage_contents: { en: 'Contents', th: 'ทรัพย์สินภายใน' },
insurance_coverage_both: { en: 'Both', th: 'ทั้งสองอย่าง' },
insurance_full_name: { en: 'Full name', th: 'ชื่อ-นามสกุล' },
insurance_phone: { en: 'Phone number', th: 'เบอร์โทรศัพท์' },
insurance_email: { en: 'Email (optional)', th: 'อีเมล (ไม่บังคับ)' },
insurance_property_value: { en: 'Approximate property value (THB, optional)', th: 'มูลค่าทรัพย์สินโดยประมาณ (บาท, ไม่บังคับ)' },
insurance_notes: { en: 'Notes (optional)', th: 'หมายเหตุ (ไม่บังคับ)' },
insurance_submit: { en: 'Request Quote', th: 'ขอใบเสนอราคา' },
insurance_submitting: { en: 'Submitting…', th: 'กำลังส่ง…' },
insurance_success: { en: 'Request sent! Our team will contact you with a quote soon.', th: 'ส่งคำขอแล้ว! ทีมงานจะติดต่อกลับพร้อมใบเสนอราคาเร็วๆ นี้' },
insurance_error: { en: 'Something went wrong. Please try again.', th: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง' },
insurance_login_required: { en: 'Please sign in to request an insurance quote.', th: 'กรุณาเข้าสู่ระบบเพื่อขอใบเสนอราคาประกันภัย' },
chat_title: { en: 'Messages', th: 'ข้อความ' },
chat_search_ph: { en: 'Search chats', th: 'ค้นหาแชท' },
chat_empty: { en: 'No conversations yet', th: 'ยังไม่มีบทสนทนา' },
chat_empty_sub: { en: 'Chat with an agent from any property’s detail page.', th: 'แชทกับเอเจนต์ได้จากหน้ารายละเอียดทรัพย์' },
chat_no_messages: { en: 'No messages yet', th: 'ยังไม่มีข้อความ' },
msg_role_agent: { en: 'SVLM Agent', th: 'เอเจนต์ SVLM' },
chat_no_agent: { en: 'No agent is assigned to this property yet — please check back later.', th: 'ทรัพย์นี้ยังไม่มีเอเจนต์รับผิดชอบ กรุณาลองใหม่ภายหลัง' },
chat_start_error: { en: 'Could not start the chat. Please try again.', th: 'เริ่มแชทไม่สำเร็จ กรุณาลองใหม่' },
ai_chat_title: { en: 'SVLM Assistant', th: 'ผู้ช่วย SVLM' },
ai_chat_label: { en: 'AI Assistant', th: 'ผู้ช่วย AI' },
ai_chat_greeting: { en: 'Hi! Ask me about any property — location, price, or availability.', th: 'สวัสดีค่ะ ถามเกี่ยวกับทรัพย์ได้เลย เช่น ทำเล ราคา หรือสถานะว่าง' },
ai_chat_ph: { en: 'Ask about a property...', th: 'ถามเกี่ยวกับทรัพย์...' },
ai_chat_error: { en: 'Sorry, the assistant is unavailable right now. Please try again shortly.', th: 'ขออภัย ผู้ช่วยไม่พร้อมใช้งานตอนนี้ กรุณาลองใหม่อีกครั้ง' },
ai_chat_thinking: { en: 'Thinking…', th: 'กำลังคิด…' },
msg_online: { en: 'Online', th: 'ออนไลน์' },
msg_ph: { en: 'Message...', th: 'พิมพ์ข้อความ...' },
quick_book: { en: 'Book viewing', th: 'จองดูห้อง' },
quick_price: { en: 'Ask price', th: 'สอบถามราคา' },
quick_location: { en: 'Share location', th: 'แชร์ตำแหน่ง' },
edit_profile_title: { en: 'Edit Profile', th: 'แก้ไขโปรไฟล์' },
edit_profile_name: { en: 'Full name', th: 'ชื่อ-นามสกุล' },
edit_profile_email: { en: 'Email', th: 'อีเมล' },
edit_profile_phone: { en: 'Phone', th: 'เบอร์โทรศัพท์' },
edit_profile_phone_note: { en: 'Contact support to change your phone number.', th: 'ติดต่อฝ่ายบริการลูกค้าเพื่อเปลี่ยนเบอร์โทรศัพท์' },
edit_profile_verified: { en: 'Verified', th: 'ยืนยันแล้ว' },
edit_profile_address: { en: 'Current Address', th: 'ที่อยู่ปัจจุบัน' },
edit_profile_zip: { en: 'Zip Code', th: 'รหัสไปรษณีย์' },
edit_profile_state: { en: 'State', th: 'จังหวัด' },
edit_profile_change_password: { en: 'Change Password', th: 'เปลี่ยนรหัสผ่าน' },
edit_profile_save: { en: 'Save Changes', th: 'บันทึกการเปลี่ยนแปลง' },
profile_title: { en: 'Profile', th: 'โปรไฟล์' },
profile_general: { en: 'General', th: 'ทั่วไป' },
profile_payment_methods: { en: 'Payment Methods', th: 'วิธีการชำระเงิน' },
profile_locations: { en: 'Locations', th: 'ที่อยู่ที่บันทึกไว้' },
profile_add_social: { en: 'Add Social Account', th: 'เพิ่มบัญชีโซเชียล' },
profile_refer_friends: { en: 'Refer to Friends', th: 'แนะนำเพื่อน' },
profile_notifications: { en: 'Notifications', th: 'การแจ้งเตือน' },
profile_push_notifications: { en: 'Push Notifications', th: 'การแจ้งเตือนแบบพุช' },
profile_promotional_notifications: { en: 'Promotional Notifications', th: 'การแจ้งเตือนโปรโมชั่น' },
profile_more: { en: 'More', th: 'อื่นๆ' },
profile_contact_us: { en: 'Contact Us', th: 'ติดต่อเรา' },
profile_coming_soon: { en: 'Coming soon', th: 'เร็วๆ นี้' },
social_title: { en: 'SVLM Social Channel', th: 'ช่องทางโซเชียลของ SVLM' },
social_sub: { en: 'Follow us to stay updated on new listings and promotions.', th: 'ติดตามเราเพื่อรับข่าวสารทรัพย์ใหม่และโปรโมชั่น' },
refer_share_title: { en: 'SVLM — Buy, Sell & Rent in Thailand', th: 'SVLM — ซื้อ ขาย เช่า อสังหาริมทรัพย์ในไทย' },
refer_share_text: { en: 'Check out SVLM for property listings across Thailand!', th: 'ลองดู SVLM สำหรับทรัพย์ให้เช่า-ขายทั่วไทย!' },
contact_us_title: { en: 'Contact Us', th: 'ติดต่อเรา' },
contact_us_sub: { en: 'Reach our team directly through any of these channels.', th: 'ติดต่อทีมงานของเราได้โดยตรงผ่านช่องทางเหล่านี้' },
change_password_title: { en: 'Change Password', th: 'เปลี่ยนรหัสผ่าน' },
change_password_current: { en: 'Current Password', th: 'รหัสผ่านปัจจุบัน' },
change_password_new: { en: 'New Password', th: 'รหัสผ่านใหม่' },
change_password_confirm: { en: 'Confirm Password', th: 'ยืนยันรหัสผ่านใหม่' },
change_password_save: { en: 'Save Password', th: 'บันทึกรหัสผ่าน' },
change_password_success: { en: 'Password changed successfully.', th: 'เปลี่ยนรหัสผ่านสำเร็จแล้ว' },
change_password_mismatch: { en: 'New passwords do not match.', th: 'รหัสผ่านใหม่ไม่ตรงกัน' },
change_password_too_short: { en: 'Password must be at least 8 characters.', th: 'รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร' },
change_password_error: { en: 'Could not change password. Please try again.', th: 'เปลี่ยนรหัสผ่านไม่สำเร็จ กรุณาลองใหม่' },
cards_title: { en: 'My Cards', th: 'บัตรของฉัน' },
cards_add_new: { en: 'Add New', th: 'เพิ่มบัตรใหม่' },
cards_empty_title: { en: 'Add your card', th: 'เพิ่มบัตรของคุณ' },
cards_empty_sub: { en: "It seems like you haven't added any credit or debit card. Add a card for faster checkout.", th: 'คุณยังไม่ได้เพิ่มบัตรเครดิตหรือเดบิต เพิ่มบัตรเพื่อความรวดเร็วในการชำระเงินครั้งต่อไป' },
cards_back: { en: 'Back', th: 'กลับ' },
cards_expires: { en: 'Expires', th: 'หมดอายุ' },
cards_remove: { en: 'Remove', th: 'ลบ' },
cards_remove_confirm: { en: 'Remove this card?', th: 'ต้องการลบบัตรนี้หรือไม่?' },
cards_redirect_title: { en: "You'll be redirected to mPay", th: 'คุณกำลังจะถูกนำไปยังหน้าชำระเงินของ mPay' },
cards_redirect_sub: { en: 'Your card details are entered on mPay’s secure page and never pass through this app.', th: 'ข้อมูลบัตรของคุณจะถูกกรอกบนหน้าที่ปลอดภัยของ mPay และจะไม่ผ่านแอปนี้' },
cards_redirect_continue: { en: 'Continue', th: 'ดำเนินการต่อ' },
cards_redirect_cancel: { en: 'Cancel', th: 'ยกเลิก' },
cards_added_title: { en: 'Card Added Successfully', th: 'เพิ่มบัตรสำเร็จ' },
cards_added_sub: { en: 'Your card has been saved for next time.', th: 'บันทึกบัตรของคุณสำหรับใช้ครั้งต่อไปแล้ว' },
cards_added_back: { en: 'Back to My Cards', th: 'กลับไปที่บัตรของฉัน' },
cards_failed_title: { en: 'Could not add card', th: 'เพิ่มบัตรไม่สำเร็จ' },
cards_failed_sub: { en: 'The card was not saved. Please try again.', th: 'ไม่สามารถบันทึกบัตรได้ กรุณาลองใหม่อีกครั้ง' },
booking_cta: { en: 'Book Now', th: 'จองเลย' },
booking_title: { en: 'Book This Stay', th: 'จองที่พักนี้' },
booking_choose_term: { en: 'Choose your lease term', th: 'เลือกระยะเวลาเช่า' },
booking_term_months: { en: 'months', th: 'เดือน' },
booking_month: { en: 'month', th: 'เดือน' },
booking_move_in: { en: 'Preferred move-in date (optional)', th: 'วันที่ต้องการเข้าพัก (ไม่บังคับ)' },
booking_monthly_rate: { en: 'Monthly rate', th: 'ค่าเช่าต่อเดือน' },
booking_deposit_label: { en: 'Reservation deposit (20%)', th: 'มัดจำการจอง (20%)' },
booking_deposit_note: { en: 'Pay a 20% deposit online to reserve this unit. The rest is arranged when you sign the lease.', th: 'ชำระมัดจำ 20% ออนไลน์เพื่อจองห้องนี้ ส่วนที่เหลือดำเนินการเมื่อเซ็นสัญญาเช่า' },
booking_pay_method: { en: 'Pay with', th: 'ชำระผ่าน' },
booking_pay_qr: { en: 'PromptPay QR', th: 'QR พร้อมเพย์' },
booking_pay_card: { en: 'Credit / Debit Card', th: 'บัตรเครดิต/เดบิต' },
booking_pay_now: { en: 'Pay Deposit', th: 'ชำระมัดจำ' },
booking_error: { en: 'Could not start this booking. Please try again.', th: 'ไม่สามารถเริ่มการจองได้ กรุณาลองใหม่' },
booking_qr_title: { en: 'Scan to Pay', th: 'สแกนเพื่อชำระเงิน' },
booking_qr_sub: { en: 'Scan this QR with any Thai banking app to pay the deposit.', th: 'สแกน QR นี้ด้วยแอปธนาคารเพื่อชำระมัดจำ' },
booking_qr_waiting: { en: 'Waiting for payment…', th: 'กำลังรอการชำระเงิน…' },
booking_confirmed_title: { en: 'Booking Confirmed!', th: 'จองสำเร็จ!' },
booking_confirmed_sub: { en: 'Your deposit was received. Our team will contact you to arrange the lease.', th: 'ได้รับมัดจำของคุณแล้ว ทีมงานจะติดต่อกลับเพื่อดำเนินการทำสัญญาเช่า' },
booking_failed_title: { en: 'Payment Failed', th: 'การชำระเงินไม่สำเร็จ' },
booking_failed_sub: { en: 'The deposit payment did not go through. Please try again.', th: 'การชำระมัดจำไม่สำเร็จ กรุณาลองใหม่อีกครั้ง' },
booking_back: { en: 'Back to My Bookings', th: 'กลับไปที่การจองของฉัน' },
menu_my_bookings: { en: 'My Bookings', th: 'การจองของฉัน' },
my_bookings_title: { en: 'My Bookings', th: 'การจองของฉัน' },
my_bookings_empty: { en: 'No bookings yet.', th: 'ยังไม่มีการจอง' },
booking_status_pending_payment: { en: 'Awaiting payment', th: 'รอชำระเงิน' },
booking_status_confirmed: { en: 'Confirmed', th: 'ยืนยันแล้ว' },
booking_status_failed: { en: 'Failed', th: 'ไม่สำเร็จ' },
booking_status_cancelled: { en: 'Cancelled', th: 'ยกเลิกแล้ว' },
menu_welcome: { en: 'Welcome to SVLM Agent', th: 'ยินดีต้อนรับสู่ SVLM Agent' },
menu_my_listings: { en: 'My Listings', th: 'ทรัพย์ของฉัน' },
menu_juristic: { en: 'SVLM Juristic', th: 'SVLM นิติบุคคล' },
menu_saved_search: { en: 'Saved Search', th: 'การค้นหาที่บันทึกไว้' },
juristic_login_title: { en: 'Tenant Login', th: 'เข้าสู่ระบบผู้เช่า' },
juristic_login_sub: { en: 'Log in with the phone number on your lease to see your unit and message our team.', th: 'เข้าสู่ระบบด้วยเบอร์โทรที่ลงทะเบียนไว้ในสัญญาเช่า เพื่อดูห้องของคุณและติดต่อทีมงาน' },
juristic_phone_ph: { en: 'Phone number', th: 'เบอร์โทรศัพท์' },
juristic_send_otp: { en: 'Send code', th: 'ส่งรหัส' },
juristic_otp_ph: { en: 'Enter the code sent to your phone', th: 'กรอกรหัสที่ส่งไปยังโทรศัพท์ของคุณ' },
juristic_verify: { en: 'Verify', th: 'ยืนยัน' },
juristic_otp_error: { en: 'Could not verify — please check the code and try again.', th: 'ยืนยันไม่สำเร็จ กรุณาตรวจสอบรหัสแล้วลองใหม่' },
juristic_no_lease: { en: 'No lease found for this phone number.', th: 'ไม่พบสัญญาเช่าสำหรับเบอร์นี้' },
juristic_tab_status: { en: 'Status', th: 'สถานะ' },
juristic_tab_documents: { en: 'Documents', th: 'เอกสาร' },
juristic_tab_chat: { en: 'Chat', th: 'แชท' },
juristic_tab_service: { en: 'Request Service', th: 'ขอรับบริการ' },
juristic_unit_label: { en: 'Unit', th: 'ห้อง' },
juristic_status_renting: { en: 'Currently renting', th: 'กำลังเช่าอยู่' },
juristic_contract_period: { en: 'Contract period', th: 'ระยะเวลาสัญญา' },
juristic_rent_amount: { en: 'Monthly rent', th: 'ค่าเช่ารายเดือน' },
juristic_rent_due: { en: 'Rent due date', th: 'วันครบกำหนดชำระค่าเช่า' },
juristic_paid: { en: 'Paid', th: 'ชำระแล้ว' },
juristic_unpaid: { en: 'Not yet paid', th: 'ยังไม่ชำระ' },
juristic_addons_title: { en: 'Add-on services', th: 'บริการเสริม' },
juristic_documents_title: { en: 'Documents', th: 'เอกสาร' },
juristic_doc_booking: { en: 'Booking Form', th: 'แบบฟอร์มการจอง' },
juristic_doc_contract: { en: 'Lease Contract', th: 'สัญญาเช่า' },
juristic_doc_furniture: { en: 'Furniture List', th: 'รายการเฟอร์นิเจอร์' },
juristic_doc_download: { en: 'Download', th: 'ดาวน์โหลด' },
juristic_doc_unavailable: { en: 'Not yet available', th: 'ยังไม่พร้อมใช้งาน' },
doclist_empty: { en: 'No documents sent to you yet.', th: 'ยังไม่มีเอกสารที่ส่งถึงคุณ' },
doclist_status_sent: { en: 'Awaiting your signature', th: 'รอลายเซ็นของคุณ' },
doclist_status_signed: { en: 'Signed', th: 'เซ็นแล้ว' },
doclist_sign_button: { en: 'Sign this document', th: 'เซ็นเอกสารนี้' },
doclist_sign_name_ph: { en: 'Type your full legal name', th: 'พิมพ์ชื่อ-นามสกุลจริงของคุณ' },
doclist_sign_consent: { en: 'Typing my name above constitutes my electronic signature on this document.', th: 'การพิมพ์ชื่อด้านบนถือเป็นลายเซ็นอิเล็กทรอนิกส์ของฉันบนเอกสารนี้' },
doclist_sign_submit: { en: 'Confirm signature', th: 'ยืนยันลายเซ็น' },
doclist_sign_submitting: { en: 'Signing…', th: 'กำลังเซ็น...' },
doclist_sign_error: { en: 'Could not sign. Please try again.', th: 'เซ็นไม่สำเร็จ กรุณาลองใหม่' },
doclist_signed_by: { en: 'Signed by', th: 'เซ็นโดย' },
doclist_view_file: { en: 'View file', th: 'ดูไฟล์' },
juristic_chat_ph: { en: 'Type a message...', th: 'พิมพ์ข้อความ...' },
juristic_service_type_label: { en: 'What do you need help with?', th: 'ต้องการความช่วยเหลือเรื่องอะไร?' },
juristic_service_time_label: { en: 'Preferred date and time', th: 'วันและเวลาที่สะดวก' },
juristic_time_slot_morning: { en: 'Morning (09:00-12:00)', th: 'ช่วงเช้า (09:00-12:00)' },
juristic_time_slot_afternoon: { en: 'Afternoon (13:00-16:00)', th: 'ช่วงบ่าย (13:00-16:00)' },
juristic_time_slot_evening: { en: 'Late afternoon (16:00-18:00)', th: 'ช่วงบ่ายแก่ (16:00-18:00)' },
svc_status_open: { en: 'Open', th: 'เปิดรับ' },
svc_status_in_progress: { en: 'In progress', th: 'กำลังดำเนินการ' },
svc_status_done: { en: 'Done', th: 'เสร็จสิ้น' },
juristic_service_note_ph: { en: 'Add details (optional)', th: 'เพิ่มรายละเอียด (ถ้ามี)' },
juristic_service_submit: { en: 'Submit request', th: 'ส่งคำขอ' },
juristic_service_sent: { en: 'Request sent — our team will follow up soon.', th: 'ส่งคำขอแล้ว ทีมงานจะติดต่อกลับเร็วๆ นี้' },
juristic_service_history: { en: 'Your requests', th: 'คำขอของคุณ' },
svc_type_cleaning: { en: 'Cleaning', th: 'ทำความสะอาด' },
svc_type_handyman: { en: 'Handyman', th: 'ช่างซ่อม' },
svc_type_appliance: { en: 'Appliance', th: 'เครื่องใช้ไฟฟ้า' },
svc_type_ac: { en: 'Air conditioning', th: 'แอร์' },
svc_type_lighting: { en: 'Lighting', th: 'ไฟฟ้า/ไฟส่องสว่าง' },
svc_type_keycard: { en: 'Keycard / Parking', th: 'คีย์การ์ด/ที่จอดรถ' },
svc_type_housekeeping: { en: 'Housekeeping', th: 'แม่บ้าน' },
menu_language: { en: 'Language', th: 'ภาษา' },
menu_privacy_policy: { en: 'Privacy policy', th: 'นโยบายความเป็นส่วนตัว' },
menu_manage_account: { en: 'Manage account', th: 'จัดการบัญชี' },
menu_logout: { en: 'Logout', th: 'ออกจากระบบ' },
menu_coming_soon: { en: 'Coming soon', th: 'เร็วๆ นี้' },
nav_home: { en: 'Home', th: 'หน้าแรก' },
nav_listings: { en: 'Listings', th: 'ทรัพย์' },
nav_activities: { en: 'Activities', th: 'กิจกรรม' },
nav_message: { en: 'Message', th: 'ข้อความ' },
nav_notification: { en: 'Notification', th: 'แจ้งเตือน' },
nav_menu: { en: 'Menu', th: 'เมนู' },
activities_title: { en: 'Activities', th: 'กิจกรรม' },
activities_empty: { en: 'No activity yet', th: 'ยังไม่มีกิจกรรม' },
notif_title: { en: 'Notifications', th: 'การแจ้งเตือน' },
notif_empty: { en: 'No notifications yet', th: 'ยังไม่มีการแจ้งเตือน' },
notif_empty_sub: { en: "We'll let you know when there's something new.", th: 'เราจะแจ้งให้ทราบเมื่อมีความเคลื่อนไหวใหม่' },
notif_mark_all_read: { en: 'Mark all read', th: 'อ่านทั้งหมด' },
my_listings_title: { en: 'My Listings', th: 'ทรัพย์ของฉัน' },
my_listings_cta: { en: '+ List a new property', th: '+ ลงประกาศทรัพย์ใหม่' },
my_listings_empty: { en: "You haven't listed a property yet", th: 'คุณยังไม่ได้ลงประกาศทรัพย์' },
my_listings_empty_sub: { en: 'Submitted properties will show up here once you list one.', th: 'ทรัพย์ที่ส่งแล้วจะแสดงที่นี่หลังจากคุณลงประกาศ' },
status_pending: { en: 'Under review', th: 'กำลังตรวจสอบ' },
status_live: { en: 'Live', th: 'เผยแพร่แล้ว' },
status_reserved: { en: 'Reserved', th: 'จองแล้ว' },
status_sold: { en: 'Sold/Rented', th: 'ขาย/เช่าแล้ว' },
status_rejected: { en: 'Needs changes', th: 'ต้องแก้ไข' },
status_draft: { en: 'Draft', th: 'ฉบับร่าง' },
beds_label: { en: 'bed', th: 'นอน' },
baths_label: { en: 'bath', th: 'น้ำ' },
area_label: { en: 'sqm', th: 'ตร.ม.' },
onboard_skip: { en: 'Skip', th: 'ข้าม' },
onboard_continue: { en: 'Continue', th: 'ถัดไป' },
onboard_getstarted: { en: 'Get Started', th: 'เริ่มต้นใช้งาน' },
list_banner_title: { en: 'List your property', th: 'ฝากทรัพย์กับเรา' },
list_banner_sub: { en: 'Sell or rent it out with SVLM agents', th: 'ขายหรือปล่อยเช่ากับเอเจนต์ SVLM' },
list_banner_cta: { en: 'Start', th: 'เริ่มเลย' },
gen_inq_banner_title: { en: 'Register your inquiry', th: 'ลงทะเบียนความต้องการ' },
gen_inq_banner_sub: { en: "Tell us what you're looking for", th: 'บอกเราว่าคุณกำลังมองหาอะไร' },
gen_inq_banner_cta: { en: 'Start', th: 'เริ่มเลย' },
gen_inq_title: { en: 'Register Your Inquiry', th: 'ลงทะเบียนความต้องการ' },
gen_inq_deal_label: { en: "I'm looking to", th: 'ฉันต้องการ' },
gen_inq_deal_rent: { en: 'Rent', th: 'เช่า' },
gen_inq_deal_buy: { en: 'Buy', th: 'ซื้อ' },
gen_inq_province_label: { en: 'Preferred location', th: 'ทำเลที่สนใจ' },
gen_inq_province_ph: { en: 'Select a province', th: 'เลือกจังหวัด' },
gen_inq_specific_project_label: { en: 'Specific project (optional)', th: 'โครงการที่สนใจเป็นพิเศษ (ถ้ามี)' },
gen_inq_beds_label: { en: 'Bedrooms', th: 'จำนวนห้องนอน' },
gen_inq_beds_studio: { en: 'Studio', th: 'สตูดิโอ' },
gen_inq_sqm_label: { en: 'Size (sqm, optional)', th: 'ขนาดพื้นที่ (ตร.ม., ถ้ามี)' },
gen_inq_sqm_ph: { en: 'e.g. 30-45', th: 'เช่น 30-45' },
gen_inq_submit: { en: 'Submit Inquiry', th: 'ส่งคำถาม' },
gen_inq_submitting: { en: 'Submitting…', th: 'กำลังส่ง…' },
gen_inq_success: { en: 'Inquiry sent! An agent will contact you soon.', th: 'ส่งคำถามแล้ว! เอเจนต์จะติดต่อกลับเร็วๆ นี้' },
gen_inq_login_required: { en: 'Please sign in to send an inquiry.', th: 'กรุณาเข้าสู่ระบบเพื่อส่งคำถาม' },
list_title: { en: 'List Your Property', th: 'ฝากทรัพย์' },
list_type_sale: { en: 'For Sale', th: 'ขาย' },
list_type_rent: { en: 'For Rent', th: 'เช่า' },
list_type_both: { en: 'Both', th: 'ทั้งสอง' },
list_address_label: { en: 'Property address', th: 'ที่อยู่ทรัพย์' },
list_address_ph: { en: 'e.g. 88 Sukhumvit Rd, Bangkok', th: 'เช่น 88 ถ.สุขุมวิท กรุงเทพฯ' },
list_price_label: { en: 'Asking price (฿)', th: 'ราคาที่ต้องการ (฿)' },
list_price_ph: { en: 'e.g. 6,500,000', th: 'เช่น 6,500,000' },
list_beds_label: { en: 'Bedrooms', th: 'ห้องนอน' },
list_baths_label: { en: 'Bathrooms', th: 'ห้องน้ำ' },
list_area_label: { en: 'Area (sqm)', th: 'พื้นที่ (ตร.ม.)' },
list_far_label: { en: 'FAR (Floor Area Ratio)', th: 'FAR (อัตราส่วนพื้นที่อาคารต่อที่ดิน)' },
list_photos_label: { en: 'Photos', th: 'รูปภาพ' },
list_photos_cover_hint: { en: 'Tap a photo to make it the cover.', th: 'แตะรูปเพื่อกำหนดเป็นรูปปก' },
list_photos_cover_badge: { en: 'Cover', th: 'รูปปก' },
list_desc_label: { en: 'Description', th: 'รายละเอียด' },
list_amenities_label: { en: 'Amenities', th: 'สิ่งอำนวยความสะดวก' },
amenity_cat_security: { en: 'Security', th: 'ความปลอดภัย' },
amenity_cat_facility: { en: 'Facilities', th: 'สิ่งอำนวยความสะดวก' },
amenity_cat_furnishing: { en: 'Furnishing', th: 'เฟอร์นิเจอร์' },
amenity_cat_utility: { en: 'Utilities', th: 'สาธารณูปโภค' },
list_review_amenities: { en: 'Amenities', th: 'สิ่งอำนวยความสะดวก' },
list_desc_ph: { en: 'Tell us about the property...', th: 'บอกเราเกี่ยวกับทรัพย์ของคุณ...' },
list_sale_price_label: { en: 'Sale price (฿)', th: 'ราคาขาย (฿)' },
list_rent_price_label: { en: 'Rent price (฿/mo)', th: 'ราคาเช่า (฿/เดือน)' },
list_shortterm_label: { en: 'Short-term Leasing (Monthly)', th: 'ปล่อยเช่าระยะสั้น (รายเดือน)' },
list_shortterm_sub: { en: 'Offer this rental at more than one lease length, with its own monthly rate per length. Minimum lease term is 1 month.', th: 'เปิดให้เช่าได้หลายระยะเวลา พร้อมราคาต่อเดือนแยกตามระยะเวลา ระยะเช่าขั้นต่ำ 1 เดือน' },
list_shortterm_term: { en: 'mo', th: 'ด.' },
list_shortterm_price_ph: { en: 'Rate/mo', th: 'ราคา/เดือน' },
list_unit_label: { en: 'Unit / room no.', th: 'เลขที่ห้อง' },
list_unit_ph: { en: 'e.g. 12/34', th: 'เช่น 12/34' },
list_building_label: { en: 'Building name', th: 'ชื่ออาคาร' },
list_building_ph: { en: 'e.g. Tower A', th: 'เช่น ตึก A' },
list_map_label: { en: 'Pin location on map', th: 'ปักหมุดตำแหน่งบนแผนที่' },
list_map_search_ph: { en: 'Search for the address...', th: 'ค้นหาที่อยู่...' },
list_map_no_key: { en: 'Map search isn’t configured yet — you can still fill in the address above.', th: 'ยังไม่ได้ตั้งค่าแผนที่ค้นหา — กรอกที่อยู่ด้านบนได้ตามปกติ' },
list_transit_label: { en: 'Nearby BTS / MRT', th: 'ใกล้ BTS / MRT' },
list_unit_layout_label: { en: 'Room-Specific', th: 'ลักษณะห้องเฉพาะ' },
list_unit_layout_simplex: { en: 'Simplex', th: 'ซิมเพล็กซ์' },
list_unit_layout_duplex: { en: 'Duplex', th: 'ดูเพล็กซ์' },
list_unit_layout_penthouse: { en: 'Penthouse', th: 'เพนท์เฮาส์' },
avail_edit_title: { en: 'Availability', th: 'สถานะความพร้อม' },
avail_edit_sub: { en: 'Let SVLM know so we can find you a client.', th: 'แจ้งให้ SVLM ทราบ เพื่อช่วยหาลูกค้าให้คุณ' },
avail_ready: { en: 'Ready Now', th: 'พร้อมตอนนี้' },
avail_not_available: { en: 'Not Available', th: 'ไม่ว่าง' },
avail_available_from: { en: 'Available From', th: 'ว่างวันที่' },
avail_save: { en: 'Update Status', th: 'อัปเดตสถานะ' },
avail_saved: { en: 'Availability updated!', th: 'อัปเดตสถานะเรียบร้อย!' },
avail_error: { en: 'Could not update. Please try again.', th: 'อัปเดตไม่สำเร็จ กรุณาลองใหม่' },
owner_stat_views: { en: 'Views', th: 'จำนวนคนดู' },
owner_stat_leads: { en: 'Interested', th: 'จำนวนคนสนใจ' },
juristic_request_title: { en: 'SVLM Juristic Service', th: 'บริการนิติบุคคล SVLM' },
juristic_request_sub: { en: 'Ask us to relay a request straight to your building\'s juristic office.', th: 'ให้เราส่งคำขอถึงนิติบุคคลอาคารของคุณโดยตรง' },
juristic_request_button: { en: 'Request Juristic Service', th: 'ขอบริการนิติบุคคล' },
juristic_request_ph: { en: 'What do you need help with?', th: 'ต้องการความช่วยเหลือเรื่องอะไร' },
juristic_request_send: { en: 'Send Request', th: 'ส่งคำขอ' },
juristic_request_sending: { en: 'Sending…', th: 'กำลังส่ง...' },
juristic_request_sent: { en: 'Sent to your juristic office.', th: 'ส่งถึงนิติบุคคลแล้ว' },
juristic_request_no_contact: { en: 'No juristic contact on file for this building yet — SVLM has been notified to add one.', th: 'ยังไม่มีข้อมูลนิติบุคคลสำหรับอาคารนี้ — SVLM ได้รับแจ้งให้เพิ่มข้อมูลแล้ว' },
juristic_request_error: { en: 'Could not send. Please try again.', th: 'ส่งไม่สำเร็จ กรุณาลองใหม่' },
list_transit_bts: { en: 'BTS Skytrain', th: 'รถไฟฟ้า BTS' },
list_transit_mrt: { en: 'MRT Subway', th: 'รถไฟฟ้าใต้ดิน MRT' },
list_contact_title: { en: 'Contact details', th: 'ข้อมูลติดต่อ' },
list_contact_required_hint: { en: 'Name, phone, and email are required so our team can reach you about this listing.', th: 'กรุณากรอกชื่อ เบอร์โทร และอีเมล เพื่อให้ทีมงานติดต่อกลับเกี่ยวกับประกาศนี้ได้' },
list_contact_name: { en: 'Owner name', th: 'ชื่อเจ้าของ' },
list_contact_phone: { en: 'Phone', th: 'เบอร์โทร' },
list_contact_line: { en: 'LINE ID', th: 'LINE ID' },
list_contact_email: { en: 'Email', th: 'อีเมล' },
list_contact_whatsapp: { en: 'WhatsApp', th: 'WhatsApp' },
list_contact_facebook: { en: 'Facebook', th: 'Facebook' },
list_exclusive_label: { en: 'Exclusive / private marketing plan', th: 'ทำสัญญาปิด / Private Marketing Plan' },
list_exclusive_sub: { en: 'List with SVLM only for an agreed period — no other agents.', th: 'ฝากขาย/เช่ากับ SVLM เพียงผู้เดียวในระยะเวลาที่ตกลง ไม่ผ่านนายหน้ารายอื่น' },
list_exclusive_period: { en: 'Agreement period', th: 'ระยะเวลาสัญญา' },
list_submit: { en: 'Submit Listing', th: 'ส่งข้อมูลฝากทรัพย์' },
list_review_cta: { en: 'Review & Submit', th: 'ตรวจสอบและส่ง' },
list_review_title: { en: 'Review your listing', th: 'ตรวจสอบข้อมูลทรัพย์' },
list_review_edit: { en: 'Edit', th: 'แก้ไข' },
list_review_not_set: { en: 'Not set', th: 'ยังไม่ระบุ' },
list_review_basic: { en: 'Basic Info', th: 'ข้อมูลทั่วไป' },
list_review_location: { en: 'Location', th: 'ทำเลที่ตั้ง' },
list_review_pricing: { en: 'Pricing', th: 'ราคา' },
list_review_details: { en: 'Details', th: 'รายละเอียด' },
list_review_transit: { en: 'Nearby Transit', th: 'สถานีใกล้เคียง' },
list_review_photos: { en: 'Photos', th: 'รูปภาพ' },
list_review_desc: { en: 'Description', th: 'รายละเอียดเพิ่มเติม' },
list_review_contact: { en: 'Contact Info', th: 'ข้อมูลติดต่อ' },
list_review_exclusive: { en: 'Exclusive Agency', th: 'สัญญาปิด' },
list_review_pin_set: { en: 'Pin placed on map', th: 'ปักหมุดแล้ว' },
list_review_pin_unset: { en: 'No map pin', th: 'ยังไม่ได้ปักหมุด' },
list_success_title: { en: 'Listing submitted!', th: 'ส่งข้อมูลสำเร็จ!' },
list_success_sub: { en: 'An SVLM agent will review your property and contact you within 24 hours.', th: 'เอเจนต์ของ SVLM จะตรวจสอบทรัพย์ของคุณและติดต่อกลับภายใน 24 ชั่วโมง' },
list_success_done: { en: 'Done', th: 'เสร็จสิ้น' },
insurance_banner_title: { en: 'Protect your home', th: 'คุ้มครองที่อยู่อาศัยของคุณ' },
insurance_banner_sub: { en: 'Home & condo insurance from ฿990/year', th: 'ประกันบ้านและคอนโด เริ่มต้น 990 บาท/ปี' },
insurance_banner_cta: { en: 'Learn more', th: 'ดูรายละเอียด' },
filter_title: { en: 'Filters', th: 'ตัวกรอง' },
filter_type_all: { en: 'All types', th: 'ทุกประเภท' },
filter_price_min: { en: 'Min price', th: 'ราคาต่ำสุด' },
filter_price_max: { en: 'Max price', th: 'ราคาสูงสุด' },
filter_area: { en: 'Size (sqm)', th: 'ขนาด (ตร.ม.)' },
filter_area_min: { en: 'Min size', th: 'ขนาดต่ำสุด' },
filter_area_max: { en: 'Max size', th: 'ขนาดสูงสุด' },
filter_floor: { en: 'Floor', th: 'ชั้น' },
floor_label: { en: 'Floor', th: 'ชั้น' },
filter_floor_min: { en: 'Min floor', th: 'ชั้นต่ำสุด' },
filter_floor_max: { en: 'Max floor', th: 'ชั้นสูงสุด' },
filter_district_label: { en: 'District', th: 'เขต/อำเภอ' },
filter_district_ph: { en: 'e.g. Watthana', th: 'เช่น วัฒนา' },
filter_beds_any: { en: 'Any', th: 'ทั้งหมด' },
filter_pet_label: { en: 'Pet policy', th: 'นโยบายสัตว์เลี้ยง' },
filter_pet_any: { en: 'Any', th: 'ทั้งหมด' },
filter_pet_yes: { en: 'Pet-friendly', th: 'เลี้ยงสัตว์ได้' },
filter_pet_no: { en: 'No pets', th: 'ไม่อนุญาตสัตว์เลี้ยง' },
filter_apply: { en: 'Apply filters', th: 'ใช้ตัวกรอง' },
filter_clear: { en: 'Clear all', th: 'ล้างตัวกรอง' },
filter_save_search: { en: 'Save this search', th: 'บันทึกการค้นหานี้' },
saved_search_title: { en: 'Saved Search', th: 'การค้นหาที่บันทึกไว้' },
saved_search_empty: { en: 'No saved searches yet', th: 'ยังไม่มีการค้นหาที่บันทึกไว้' },
saved_search_empty_sub: { en: 'Save a search from the Filters screen to find it here.', th: 'บันทึกการค้นหาจากหน้าตัวกรองเพื่อดูที่นี่' },
saved_search_saved: { en: 'Search saved!', th: 'บันทึกการค้นหาแล้ว!' },
saved_search_all_types: { en: 'All types', th: 'ทุกประเภท' },
};
// `value` is the whole-word term matched server-side against the raw legacy
// `type` string (e.g. "Condominium", "TownHouse") — the real data's wording
// doesn't match this app's own category names (no listing has type "Land"
// as its own word for Townhome, etc.).
// The 12 canonical categories (shared with Ops/Agent via SvlmApi) — the
// backend maps each one to an OR-keyword regex so filtering also surfaces
// matching legacy freeform type values ("Condominium", "Villa House", etc).
const CUST_TYPE_FILTER_OPTIONS = SvlmApi.PROPERTY_CATEGORIES.map(c => ({ value: c, en: c, th: SvlmApi.PROPERTY_CATEGORIES_TH[c] }));
/* ---------------- Mock data (matches the design handoff's raw arrays) ---------------- */
const CUST_ONBOARD = [
{ img: 'assets/onboarding/onboard-1.jpg', title: { en: 'Find your perfect place', th: 'ค้นหาที่อยู่ในฝันของคุณ' }, sub: { en: 'Explore thousands of homes, condos, and land across Thailand.', th: 'สำรวจบ้าน คอนโด และที่ดินหลายพันรายการทั่วประเทศไทย' } },
{ img: 'assets/onboarding/onboard-2.jpg', title: { en: 'Chat with real agents instantly', th: 'แชทกับเอเจนต์ตัวจริงได้ทันที' }, sub: { en: 'Get answers, book viewings, and close deals in one conversation.', th: 'สอบถาม นัดดูห้อง และปิดดีลได้ในแชทเดียว' } },
{ img: 'assets/onboarding/onboard-3.jpg', title: { en: 'List your property in minutes', th: 'ฝากทรัพย์ได้ในไม่กี่นาที' }, sub: { en: "Sell or rent out your property with SVLM's expert agents.", th: 'ขายหรือปล่อยเช่าทรัพย์ของคุณกับทีมเอเจนต์มืออาชีพของ SVLM' } },
{ img: 'assets/onboarding/onboard-4.jpg', title: { en: 'One app for every service', th: 'แอปเดียวครบทุกบริการ' }, sub: { en: 'Cleaning, maintenance, insurance, and mortgage — all in one place.', th: 'ทำความสะอาด ซ่อมบำรุง ประกันภัย และสินเชื่อ ครบในที่เดียว' } },
{ img: 'assets/onboarding/onboard-5.jpg', title: { en: 'Stay updated in real time', th: 'อัปเดตทุกความเคลื่อนไหวแบบเรียลไทม์' }, sub: { en: 'Track bookings, payments, and requests wherever you are.', th: 'ติดตามการจอง การชำระเงิน และคำขอบริการได้ทุกที่' } },
];
const CUST_FEATURED_LOCATIONS = [
{ en: 'Bangkok', th: 'กรุงเทพฯ' }, { en: 'Phuket', th: 'ภูเก็ต' }, { en: 'Pattaya', th: 'พัทยา' },
{ en: 'Chiang Mai', th: 'เชียงใหม่' }, { en: 'Khao Yai', th: 'เขาใหญ่' }, { en: 'Hua Hin', th: 'หัวหิน' },
];
// "Prime Location" quick-links on Home — tapping one jumps to Search with that
// area name as the keyword query (matches against title/district, same as typing
// it into the search box directly). Grouped by the region the areas fall under.
// Real Bangkok transit stations for the "List your property" nearby-transit picker,
// kept as two separate groups (BTS Skytrain vs MRT subway) per request, each further
// grouped by line since flat lists of 40-50+ stops are unusable in a mobile picker.
const CUST_BTS_LINES = [
{ line: 'Sukhumvit Line', stations: [
'Khu Khot', 'Yaek Kor Por Aor', 'Royal Thai Air Force Museum', 'Bhumibol Adulyadej Hospital', 'Saphan Mai', 'Sai Yud',
'Phahon Yothin 59', 'Wat Phra Sri Mahathat', '11th Infantry Regiment', 'Bang Bua', 'Kasetsart University', 'Sena Nikhom',
'Ratchayothin', 'Phahon Yothin 24', 'Ha Yaek Lat Phrao', 'Mo Chit', 'Saphan Khwai', 'Ari', 'Sanam Pao', 'Victory Monument',
'Phaya Thai', 'Ratchathewi', 'Siam', 'Chit Lom', 'Phloen Chit', 'Nana', 'Asok', 'Phrom Phong', 'Thong Lo', 'Ekkamai',
'Phra Khanong', 'On Nut', 'Bang Chak', 'Punnawithi', 'Udom Suk', 'Bang Na', 'Bearing', 'Samrong', 'Pu Chao',
'Chang Erawan', 'Royal Thai Naval Academy', 'Pak Nam', 'Srinagarindra', 'Phraek Sa', 'Sai Luat', 'Kheha',
] },
{ line: 'Silom Line', stations: [
'National Stadium', 'Siam', 'Ratchadamri', 'Sala Daeng', 'Chong Nonsi', 'Saint Louis', 'Surasak', 'Saphan Taksin',
'Krung Thon Buri', 'Wongwian Yai', 'Pho Nimit', 'Talat Phlu', 'Wutthakat', 'Bang Wa',
] },
];
const CUST_MRT_LINES = [
{ line: 'Blue Line', stations: [
'Lak Song', 'Bang Khae', 'Phasi Charoen', 'Bang Phai', 'Bang Wa', 'Phetkasem 48', 'Bang Khun Non', 'Bang Yi Khan',
'Sirindhorn', 'Bang Phlat', 'Bang O', 'Bang Pho', 'Tao Poon', 'Bang Sue', 'Kamphaeng Phet', 'Chatuchak Park',
'Phahon Yothin', 'Lat Phrao', 'Ratchadaphisek', 'Sutthisan', 'Huai Khwang', 'Thailand Cultural Centre', 'Phra Ram 9',
'Phetchaburi', 'Sukhumvit', 'Queen Sirikit National Convention Centre', 'Khlong Toei', 'Lumphini', 'Silom', 'Sam Yan',
'Hua Lamphong', 'Wat Mangkon', 'Sam Yot', 'Sanam Chai', 'Itsaraphap', 'Tha Phra', 'Charan 13', 'Fai Chai',
] },
{ line: 'Purple Line', stations: [
'Khlong Bang Phai', 'Talad Bang Yai', 'Sam Yaek Bang Yai', 'Bang Phlu', 'Bang Rak Yai', 'Bang Rak Noi Tha It', 'Sai Ma',
'Phra Nang Klao Bridge', 'Yaek Nonthaburi 1', 'Bang Krasor', 'Nonthaburi Civic Center', 'Ministry of Public Health',
'Yaek Tiwanon', 'Wong Sawang', 'Bang Son', 'Tao Poon',
] },
];
const CUST_PRIME_LOCATIONS = [
{ region: { en: 'Bangkok', th: 'กรุงเทพฯ' }, areas: ['Sukhumvit', 'Sathorn', 'Ratchada', 'Ari', 'Onnut', 'Ratchathewi'] },
{ region: { en: 'Phuket', th: 'ภูเก็ต' }, areas: ['Rawai', 'Khao Khwang', 'Cherng Talay', 'Bang Tao', 'Kathu', 'Thalang'] },
{ region: { en: 'Chiang Mai', th: 'เชียงใหม่' }, areas: ['Nimman', 'Chang Klan', 'Tha Phae', 'Fa Ham', 'Hang Dong', 'Nong Kwai'] },
{ region: { en: 'Hua Hin', th: 'หัวหิน' }, areas: ['Hua Hin Central', 'Khao Tao', 'Cha-am'] },
{ region: { en: 'Pattaya', th: 'พัทยา' }, areas: ['Wongamat', 'Na Kluea', 'Jomtien', 'Pratumnak', 'Bang Saray', 'Khao Chi Chan'] },
{ region: { en: 'Khao Yai', th: 'เขาใหญ่' }, areas: ['Mu Si', 'Khlong Phai', 'Pak Chong', 'Pong Ta Long', 'Klang Muang', 'Wang Nam Khiao'] },
];
const CUST_PROVINCES_77 = [
{ en: 'Bangkok', th: 'กรุงเทพมหานคร' }, { en: 'Amnat Charoen', th: 'อำนาจเจริญ' }, { en: 'Ang Thong', th: 'อ่างทอง' },
{ en: 'Bueng Kan', th: 'บึงกาฬ' }, { en: 'Buri Ram', th: 'บุรีรัมย์' }, { en: 'Chachoengsao', th: 'ฉะเชิงเทรา' },
{ en: 'Chai Nat', th: 'ชัยนาท' }, { en: 'Chaiyaphum', th: 'ชัยภูมิ' }, { en: 'Chanthaburi', th: 'จันทบุรี' },
{ en: 'Chiang Mai', th: 'เชียงใหม่' }, { en: 'Chiang Rai', th: 'เชียงราย' }, { en: 'Chonburi', th: 'ชลบุรี' },
{ en: 'Chumphon', th: 'ชุมพร' }, { en: 'Kalasin', th: 'กาฬสินธุ์' }, { en: 'Kamphaeng Phet', th: 'กำแพงเพชร' },
{ en: 'Kanchanaburi', th: 'กาญจนบุรี' }, { en: 'Khon Kaen', th: 'ขอนแก่น' }, { en: 'Krabi', th: 'กระบี่' },
{ en: 'Lampang', th: 'ลำปาง' }, { en: 'Lamphun', th: 'ลำพูน' }, { en: 'Loei', th: 'เลย' },
{ en: 'Lopburi', th: 'ลพบุรี' }, { en: 'Mae Hong Son', th: 'แม่ฮ่องสอน' }, { en: 'Maha Sarakham', th: 'มหาสารคาม' },
{ en: 'Mukdahan', th: 'มุกดาหาร' }, { en: 'Nakhon Nayok', th: 'นครนายก' }, { en: 'Nakhon Pathom', th: 'นครปฐม' },
{ en: 'Nakhon Phanom', th: 'นครพนม' }, { en: 'Nakhon Ratchasima', th: 'นครราชสีมา' }, { en: 'Nakhon Sawan', th: 'นครสวรรค์' },
{ en: 'Nakhon Si Thammarat', th: 'นครศรีธรรมราช' }, { en: 'Nan', th: 'น่าน' }, { en: 'Narathiwat', th: 'นราธิวาส' },
{ en: 'Nong Bua Lam Phu', th: 'หนองบัวลำภู' }, { en: 'Nong Khai', th: 'หนองคาย' }, { en: 'Nonthaburi', th: 'นนทบุรี' },
{ en: 'Pathum Thani', th: 'ปทุมธานี' }, { en: 'Pattani', th: 'ปัตตานี' }, { en: 'Phang Nga', th: 'พังงา' },
{ en: 'Phatthalung', th: 'พัทลุง' }, { en: 'Phayao', th: 'พะเยา' }, { en: 'Phetchabun', th: 'เพชรบูรณ์' },
{ en: 'Phetchaburi', th: 'เพชรบุรี' }, { en: 'Phichit', th: 'พิจิตร' }, { en: 'Phitsanulok', th: 'พิษณุโลก' },
{ en: 'Phra Nakhon Si Ayutthaya', th: 'พระนครศรีอยุธยา' }, { en: 'Phrae', th: 'แพร่' }, { en: 'Phuket', th: 'ภูเก็ต' },
{ en: 'Prachinburi', th: 'ปราจีนบุรี' }, { en: 'Prachuap Khiri Khan', th: 'ประจวบคีรีขันธ์' }, { en: 'Ranong', th: 'ระนอง' },
{ en: 'Ratchaburi', th: 'ราชบุรี' }, { en: 'Rayong', th: 'ระยอง' }, { en: 'Roi Et', th: 'ร้อยเอ็ด' },
{ en: 'Sa Kaeo', th: 'สระแก้ว' }, { en: 'Sakon Nakhon', th: 'สกลนคร' }, { en: 'Samut Prakan', th: 'สมุทรปราการ' },
{ en: 'Samut Sakhon', th: 'สมุทรสาคร' }, { en: 'Samut Songkhram', th: 'สมุทรสงคราม' }, { en: 'Saraburi', th: 'สระบุรี' },
{ en: 'Satun', th: 'สตูล' }, { en: 'Sing Buri', th: 'สิงห์บุรี' }, { en: 'Sisaket', th: 'ศรีสะเกษ' },
{ en: 'Songkhla', th: 'สงขลา' }, { en: 'Sukhothai', th: 'สุโขทัย' }, { en: 'Suphan Buri', th: 'สุพรรณบุรี' },
{ en: 'Surat Thani', th: 'สุราษฎร์ธานี' }, { en: 'Surin', th: 'สุรินทร์' }, { en: 'Tak', th: 'ตาก' },
{ en: 'Trang', th: 'ตรัง' }, { en: 'Trat', th: 'ตราด' }, { en: 'Ubon Ratchathani', th: 'อุบลราชธานี' },
{ en: 'Udon Thani', th: 'อุดรธานี' }, { en: 'Uthai Thani', th: 'อุทัยธานี' }, { en: 'Uttaradit', th: 'อุตรดิตถ์' },
{ en: 'Yala', th: 'ยะลา' }, { en: 'Yasothon', th: 'ยโสธร' },
];
// Popular market areas (Bangkok, Phuket, Pattaya, etc.) use short-form Thai
// names in the actual database ("กรุงเทพฯ"), which differ from the formal
// names in the full 77-province list ("กรุงเทพมหานคร") — check the popular
// list first so filtering/submission actually matches real data. Module-level
// so every form that collects a province (search filters, General Inquiry,
// self-listing) converts the same way before it reaches the API.
function provinceToThai(label) {
if (!label) return undefined;
const featured = CUST_FEATURED_LOCATIONS.find(p => p.en === label || p.th === label);
if (featured) return featured.th;
const full = CUST_PROVINCES_77.find(p => p.en === label || p.th === label);
return full ? full.th : label;
}
const CUST_CATEGORY_KEYS = [
{ en: 'Buy', th: 'ซื้อ' }, { en: 'Rent', th: 'เช่า' }, { en: 'Condo', th: 'คอนโด' },
{ en: 'House', th: 'บ้าน' }, { en: 'Land', th: 'ที่ดิน' }, { en: 'Commercial', th: 'พาณิชย์' },
{ en: 'Office', th: 'สำนักงาน' }, { en: 'Short-term', th: 'รายวัน' },
];
// Shared with Ops/Agent (SvlmApi.PROPERTY_CATEGORIES) — keeps the category taken
// on a customer's own listing consistent with what staff use and what search filters on.
const CUST_LIST_CATEGORY_KEYS = SvlmApi.PROPERTY_CATEGORIES.map(c => ({ en: c, th: SvlmApi.PROPERTY_CATEGORIES_TH[c] }));
const CUST_LAND_CATEGORIES = ['Land', 'Manufacturing', 'Warehouse'];
// Home screen's category grid — same values the search filter and the
// self-listing form already use (SvlmApi.PROPERTY_CATEGORIES), so every
// tile actually filters to real matching listings rather than a
// hand-picked subset that doesn't line up with the data.
const CUST_HOME_CATEGORIES = [
{ type: 'Condo', icon: 'building' },
{ type: 'House', icon: 'home' },
{ type: 'Townhome', icon: 'townhome' },
{ type: 'Penthouse', icon: 'penthouse' },
{ type: 'Office', icon: 'grid' },
{ type: 'Shop house', icon: 'key' },
{ type: 'Hotel', icon: 'bed' },
{ type: 'Apartment Building', icon: 'users' },
{ type: 'Showroom', icon: 'eye' },
{ type: 'Condo Pet friendly', icon: 'heart' },
{ type: 'Manufacturing', icon: 'factory' },
{ type: 'Warehouse', icon: 'warehouse' },
{ type: 'Land', icon: 'ruler' },
{ type: 'Pool Villa', icon: 'pool' },
{ type: 'Restaurant', icon: 'star' },
{ type: 'Business Selling', icon: 'wallet' },
].map(c => ({ ...c, label: { en: c.type, th: SvlmApi.PROPERTY_CATEGORIES_TH[c.type] } }));
const CUST_PROPERTIES_RAW = [
{ id: 1, tag: 'hot', name: { en: 'The Reserve Thonglor', th: 'เดอะ รีเสิร์ฟ ทองหล่อ' }, loc: { en: 'Thonglor, Bangkok', th: 'ทองหล่อ, กรุงเทพฯ' }, price: '฿8,900,000', priceValue: 8900000, beds: 2, baths: 2, area: 68 },
{ id: 2, tag: 'new', name: { en: 'Ashton Silom', th: 'แอชตัน สีลม' }, loc: { en: 'Silom, Bangkok', th: 'สีลม, กรุงเทพฯ' }, price: '฿6,200,000', priceValue: 6200000, beds: 1, baths: 1, area: 42 },
{ id: 3, tag: 'luxury', name: { en: 'The Monument Sanampao', th: 'เดอะ โมนูเมนต์ สนามเป้า' }, loc: { en: 'Phaya Thai, Bangkok', th: 'พญาไท, กรุงเทพฯ' }, price: '฿12,500,000', priceValue: 12500000, beds: 3, baths: 3, area: 110 },
{ id: 4, tag: 'rent', name: { en: 'Noble Recole', th: 'โนเบิล รีโคล' }, loc: { en: 'Sukhumvit, Bangkok', th: 'สุขุมวิท, กรุงเทพฯ' }, price: '฿35,000/mo', priceValue: null, beds: 2, baths: 1, area: 55 },
{ id: 5, tag: 'hot', name: { en: 'Life Asoke', th: 'ไลฟ์ อโศก' }, loc: { en: 'Asoke, Bangkok', th: 'อโศก, กรุงเทพฯ' }, price: '฿5,450,000', priceValue: 5450000, beds: 1, baths: 1, area: 35 },
];
const CUST_SHORT_TERM_RAW = [
{ id: 101, tag: 'shortterm', name: { en: 'Sea View Villa', th: 'วิลล่าวิวทะเล' }, loc: { en: 'Kamala, Phuket', th: 'กมลา, ภูเก็ต' }, price: '฿4,500/night', priceValue: null, beds: 3, baths: 3, area: 180 },
{ id: 102, tag: 'shortterm', name: { en: 'Hillside Pool Villa', th: 'พูลวิลล่าวิวเขา' }, loc: { en: 'Khao Yai', th: 'เขาใหญ่' }, price: '฿6,900/night', priceValue: null, beds: 4, baths: 3, area: 220 },
{ id: 103, tag: 'shortterm', name: { en: 'Beachfront Condo', th: 'คอนโดหน้าหาด' }, loc: { en: 'Jomtien, Pattaya', th: 'จอมเทียน, พัทยา' }, price: '฿1,800/night', priceValue: null, beds: 1, baths: 1, area: 45 },
{ id: 104, tag: 'shortterm', name: { en: 'Retreat House', th: 'บ้านพักตากอากาศ' }, loc: { en: 'Hua Hin', th: 'หัวหิน' }, price: '฿3,200/night', priceValue: null, beds: 2, baths: 2, area: 95 },
];
// Home screen sections below "Prime Location" — sample/placeholder content
// (no real developer, province-stats, or blog data source exists yet).
const CUST_HOME_NEW_PROJECTS = [
{ name: 'The Grand Bangkok Riverside', loc: { en: 'Charoen Nakhon, Bangkok', th: 'เจริญนคร, กรุงเทพฯ' }, type: { en: 'Condominium', th: 'คอนโดมิเนียม' }, startingPrice: '2.25M', photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/79/Chapter_Charoen_Nakhon_Bangkok.jpg/250px-Chapter_Charoen_Nakhon_Bangkok.jpg' },
{ name: 'Aspire Sukhumvit–Onnut', loc: { en: 'On Nut, Bangkok', th: 'อ่อนนุช, กรุงเทพฯ' }, type: { en: 'Condominium', th: 'คอนโดมิเนียม' }, startingPrice: '2.89M', photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/Condominium_The_Bangkok.jpg/250px-Condominium_The_Bangkok.jpg' },
{ name: 'Notting Hill Ratchayothin', loc: { en: 'Ratchayothin, Bangkok', th: 'รัชโยธิน, กรุงเทพฯ' }, type: { en: 'Townhome', th: 'ทาวน์โฮม' }, startingPrice: '4.5M', photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Rattanakosin_View_Mansion_%2826917920441%29.jpg/250px-Rattanakosin_View_Mansion_%2826917920441%29.jpg' },
];
// Real, freely-licensed developer logos (Wikimedia Commons / Wikipedia) —
// Supalai has no freely-licensed logo available, so Pruksa (another major
// SET-listed Thai developer) stands in rather than using a non-free image.
const CUST_HOME_DEVELOPERS = [
{ name: 'SVLM Development', logo: 'assets/svlm-logo.jpg' },
{ name: 'Sansiri', logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/Logo_of_Sansiri_%28en%29.svg/250px-Logo_of_Sansiri_%28en%29.svg.png' },
{ name: 'AP Thailand', logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/AP_Thai_Red-Logo.svg/250px-AP_Thai_Red-Logo.svg.png' },
{ name: 'Pruksa', logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Pruksa_Holding_Logo.svg/250px-Pruksa_Holding_Logo.svg.png' },
];
const CUST_HOME_PROVINCES = [
{ name: { en: 'Bangkok', th: 'กรุงเทพฯ' }, count: '12,400+', photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/7d/4Y1A1159_Bangkok_%2833536795515%29.jpg/330px-4Y1A1159_Bangkok_%2833536795515%29.jpg' },
{ name: { en: 'Chonburi', th: 'ชลบุรี' }, count: '3,100+', photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Central_Pattaya_and_beach_at_sunset_in_2017.jpg/330px-Central_Pattaya_and_beach_at_sunset_in_2017.jpg' },
{ name: { en: 'Phuket', th: 'ภูเก็ต' }, count: '2,600+', photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Patong_Beach.jpg/250px-Patong_Beach.jpg' },
{ name: { en: 'Chiang Mai', th: 'เชียงใหม่' }, count: '1,800+', photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Panoramic_view_of_Chiang_Mai_City.jpg/330px-Panoramic_view_of_Chiang_Mai_City.jpg' },
];
// One "Top Locations" carousel per Prime Location region — area names stay
// as plain strings (untranslated), matching CUST_PRIME_LOCATIONS's own
// convention, since these place names aren't given Thai forms there either.
// `photos` are real, freely-licensed photos of the region itself (Wikimedia
// Commons) — cycled across that region's area cards (round-robin by index)
// so adjacent cards don't repeat the same image, since no per-neighborhood
// photo source exists yet.
const CUST_HOME_TOP_LOCATIONS_BY_REGION = [
{ region: { en: 'Bangkok', th: 'กรุงเทพฯ' }, photos: [
'https://upload.wikimedia.org/wikipedia/commons/thumb/7/7d/4Y1A1159_Bangkok_%2833536795515%29.jpg/330px-4Y1A1159_Bangkok_%2833536795515%29.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/a/ad/Bangkok_%2828384757505%29.jpg/250px-Bangkok_%2828384757505%29.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Bangkok_skyline_at_sunset%2C_BTS_Skytrain%2C_Bangkok%2C_Thailand.jpg/330px-Bangkok_skyline_at_sunset%2C_BTS_Skytrain%2C_Bangkok%2C_Thailand.jpg',
], areas: [
{ name: 'Sukhumvit', count: '3,200+' }, { name: 'Sathorn', count: '1,950+' }, { name: 'Ratchada', count: '1,400+' },
{ name: 'Ari', count: '1,150+' }, { name: 'Onnut', count: '980+' }, { name: 'Ratchathewi', count: '860+' },
] },
{ region: { en: 'Phuket', th: 'ภูเก็ต' }, photos: [
'https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Patong_Beach.jpg/250px-Patong_Beach.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Kata_Beach_%285730364074%29.jpg/330px-Kata_Beach_%285730364074%29.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/Patong_Beach_%281%29.jpg/330px-Patong_Beach_%281%29.jpg',
], areas: [
{ name: 'Rawai', count: '620+' }, { name: 'Khao Khwang', count: '540+' }, { name: 'Cherng Talay', count: '1,200+' },
{ name: 'Bang Tao', count: '980+' }, { name: 'Kathu', count: '860+' }, { name: 'Thalang', count: '410+' },
] },
{ region: { en: 'Chiang Mai', th: 'เชียงใหม่' }, photos: [
'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Panoramic_view_of_Chiang_Mai_City.jpg/330px-Panoramic_view_of_Chiang_Mai_City.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Chiang_Mai_sunday_evening_walking_street.jpg/330px-Chiang_Mai_sunday_evening_walking_street.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Wat_Phra_Sing%2C_Chiang_Mai_%28I%29.jpg/250px-Wat_Phra_Sing%2C_Chiang_Mai_%28I%29.jpg',
], areas: [
{ name: 'Nimman', count: '1,050+' }, { name: 'Chang Klan', count: '640+' }, { name: 'Tha Phae', count: '590+' },
{ name: 'Fa Ham', count: '380+' }, { name: 'Hang Dong', count: '420+' }, { name: 'Nong Kwai', count: '260+' },
] },
{ region: { en: 'Hua Hin', th: 'หัวหิน' }, photos: [
'https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Hua_Hin_hotels.jpg/250px-Hua_Hin_hotels.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/7/7a/01_Hua_Hin_Beach.jpg/250px-01_Hua_Hin_Beach.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c6/Hua_Hin_Beach_evening.jpg/250px-Hua_Hin_Beach_evening.jpg',
], areas: [
{ name: 'Hua Hin Central', count: '520+' }, { name: 'Khao Tao', count: '260+' }, { name: 'Cha-am', count: '340+' },
] },
{ region: { en: 'Pattaya', th: 'พัทยา' }, photos: [
'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Pattaya_beach_from_view_point.jpg/250px-Pattaya_beach_from_view_point.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Skyline_of_Pattaya_city_from_the_Ocean_Lana%2C_Cafe_Ferry.jpg/330px-Skyline_of_Pattaya_city_from_the_Ocean_Lana%2C_Cafe_Ferry.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6e/View_of_Pattaya_from_Somerset_Pattaya_Hotel.jpg/250px-View_of_Pattaya_from_Somerset_Pattaya_Hotel.jpg',
], areas: [
{ name: 'Wongamat', count: '890+' }, { name: 'Na Kluea', count: '760+' }, { name: 'Jomtien', count: '1,300+' },
{ name: 'Pratumnak', count: '950+' }, { name: 'Bang Saray', count: '410+' }, { name: 'Khao Chi Chan', count: '300+' },
] },
{ region: { en: 'Khao Yai', th: 'เขาใหญ่' }, photos: [
'https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Haew_Suwat_Waterfall_Khao-Yai02.jpg/330px-Haew_Suwat_Waterfall_Khao-Yai02.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/Nong_Phak_Chi_grassland_at_Khao_Yai_national_park_2014.jpg/250px-Nong_Phak_Chi_grassland_at_Khao_Yai_national_park_2014.jpg',
], areas: [
{ name: 'Mu Si', count: '340+' }, { name: 'Khlong Phai', count: '210+' }, { name: 'Pak Chong', count: '480+' },
{ name: 'Pong Ta Long', count: '190+' }, { name: 'Klang Muang', count: '150+' }, { name: 'Wang Nam Khiao', count: '260+' },
] },
];
const CUST_HOME_BLOG = {
featured: {
tag: { en: 'Condo Review', th: 'รีวิวคอนโด' },
title: { en: 'Condo Review: The Grand Bangkok Riverside — pet friendly, move-in ready', th: 'รีวิวคอนโด: The Grand Bangkok Riverside เลี้ยงสัตว์ได้ พร้อมอยู่' },
author: 'SVLM Editorial',
photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Condominium_%C3%A0_Bangkok.jpg/330px-Condominium_%C3%A0_Bangkok.jpg',
},
list: [
{ category: { en: 'Home & Condo Decor', th: 'แต่งบ้าน-แต่งคอนโด' }, title: { en: 'Do you need a permit to renovate your home? What owners should know', th: 'ต่อเติมบ้านต้องขออนุญาตไหม? สิ่งที่เจ้าของบ้านควรรู้' }, date: '01/09/2026', photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Bungalow_in_Larchfield_Park_undergoing_extension_and_renovation_-_geograph.org.uk_-_8003997.jpg/250px-Bungalow_in_Larchfield_Park_undergoing_extension_and_renovation_-_geograph.org.uk_-_8003997.jpg' },
{ category: { en: 'Investing', th: 'การลงทุน' }, title: { en: '5 Bangkok neighborhoods worth watching for condo investment in 2026', th: '5 ทำเลน่าลงทุนคอนโดปี 2026' }, date: '26/08/2026', photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Bangkok_skyline_at_sunset%2C_BTS_Skytrain%2C_Bangkok%2C_Thailand.jpg/250px-Bangkok_skyline_at_sunset%2C_BTS_Skytrain%2C_Bangkok%2C_Thailand.jpg' },
{ category: { en: 'Lifestyle', th: 'ไลฟ์สไตล์ชาวอสังหาฯ' }, title: { en: 'Car scratched in the condo parking lot — can you claim insurance?', th: 'รถโดนเฉี่ยวในลานจอดคอนโด ทำอย่างไรเคลมได้ไหม' }, date: '25/08/2026', photo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Interior_of_Multi-storey_car_park_at_Skygate%2C_Brisbane.jpg/250px-Interior_of_Multi-storey_car_park_at_Skygate%2C_Brisbane.jpg' },
],
};
const CUST_TAGS = {
hot: { label: { en: 'Hot Deal', th: 'ดีลร้อน' }, bg: '#FF6A1A' },
new: { label: { en: 'New', th: 'ใหม่' }, bg: '#2FAE66' },
luxury: { label: { en: 'Luxury', th: 'หรูหรา' }, bg: '#201812' },
rent: { label: { en: 'For Rent', th: 'ให้เช่า' }, bg: '#fff', color: '#FF6A1A', border: '1px solid #FF6A1A' },
sale: { label: { en: 'For Sale', th: 'ขาย' }, bg: '#fff', color: '#FF6A1A', border: '1px solid #FF6A1A' },
both: { label: { en: 'Rent & Sale', th: 'เช่า/ขาย' }, bg: '#fff', color: '#FF6A1A', border: '1px solid #FF6A1A' },
shortterm: { label: { en: 'Short-term', th: 'เช่ารายวัน' }, bg: '#0EA5A0' },
};
/** Move-in status Ops/Staff set explicitly (or the owner confirms by SMS) — null when nobody's set it yet, so nothing renders. */
function custAvailabilityBadge(p, lang) {
const style = { bg: '#fff', color: '#FF6A1A', border: '1px solid #FF6A1A' };
if (p.availabilityStatus === 'ready') {
return { ...style, label: p.deal === 'sale' ? (lang === 'en' ? 'Ready for Sale' : 'พร้อมขาย') : (lang === 'en' ? 'Ready to Move' : 'พร้อมเข้าอยู่') };
}
if (p.availabilityStatus === 'not_available') {
return { ...style, label: lang === 'en' ? 'Not Available' : 'ไม่ว่าง' };
}
if (p.availabilityStatus === 'sold') {
return { ...style, label: lang === 'en' ? 'Sold' : 'ขายแล้ว' };
}
// Independent of availabilityStatus -- can be true alongside any status above.
if (p.wantsToSell) {
return { ...style, label: lang === 'en' ? 'Wants to Sell' : 'ต้องการขาย' };
}
if (p.availabilityStatus === 'available_from' && p.availabilityDate) {
const dateStr = new Date(p.availabilityDate).toLocaleDateString(lang === 'en' ? 'en-GB' : 'th-TH', { day: 'numeric', month: 'short' });
return { ...style, label: (lang === 'en' ? 'Available ' : 'ว่างวันที่ ') + dateStr };
}
return null;
}
const NOTIF_ICON = {
chat_message: 'chat',
listing_status: 'building',
viewing_requested: 'calendar',
booking_status: 'calendar',
service_request_update: 'key',
bill_reminder: 'wallet',
document_ready: 'doc',
saved_search_match: 'heart',
};
const CUST_DOC_TYPE_LABEL = {
booking_contract: { en: 'Booking Contract', th: 'สัญญาจอง' },
leasing_contract: { en: 'Leasing Contract', th: 'สัญญาเช่า' },
furniture_list: { en: 'Furniture List', th: 'รายการเฟอร์นิเจอร์' },
quotation: { en: 'Quotation', th: 'ใบเสนอราคา' },
invoice: { en: 'Invoice', th: 'ใบแจ้งหนี้' },
billing_note: { en: 'Billing Note', th: 'ใบวางบิล' },
tax_invoice: { en: 'Tax Invoice', th: 'ใบกำกับภาษี' },
receipt: { en: 'Receipt', th: 'ใบเสร็จรับเงิน' },
expense: { en: 'Expense', th: 'ค่าใช้จ่าย' },
};
const CUST_AMENITIES = {
en: ['Swimming Pool', 'Fitness Center', 'Co-working', 'Garden', '24hr Security'],
th: ['สระว่ายน้ำ', 'ฟิตเนส', 'พื้นที่ทำงาน', 'สวน', 'รักษาความปลอดภัย 24 ชม.'],
};
// Maps the real GET /listings (PublicListingDto) shape into what these
// screens were originally built against (CUST_PROPERTIES_RAW mock shape).
function formatCustListing(l, lang) {
const cover = (l.photos || []).find(p => p.isCover) || (l.photos || [])[0];
const priceValue = Number(l.price) || 0;
// A property listed for both sale and rent carries both prices in dealPrices — show both together.
const sale = l.dealPrices && l.dealPrices.sale != null ? Number(l.dealPrices.sale) : null;
const rent = l.dealPrices && l.dealPrices.rent != null ? Number(l.dealPrices.rent) : null;
const price = (sale != null && rent != null)
? (lang === 'en'
? 'Sale ฿' + sale.toLocaleString('en-US') + ' / Rent ฿' + rent.toLocaleString('en-US') + '/mo'
: 'ขาย ฿' + sale.toLocaleString('en-US') + ' / เช่า ฿' + rent.toLocaleString('en-US') + '/ด.')
: l.deal === 'rent'
? '฿' + priceValue.toLocaleString('en-US') + (lang === 'en' ? '/mo' : '/ด.')
: '฿' + priceValue.toLocaleString('en-US');
return {
id: l.id,
name: l.title.th || l.title.en || l.code,
loc: [l.district, l.province].filter(Boolean).join(', '),
desc: l.description || '',
price,
dealPrices: l.dealPrices || null,
priceValue: sale != null ? sale : (l.deal === 'rent' ? null : priceValue),
deal: l.deal,
coverUrl: cover ? SvlmApi.resolveFileUrl(cover.url) : null,
// Full gallery — the detail screen carousel needs every photo, not just the cover.
photos: (l.photos || []).slice().sort((a, b) => a.order - b.order).map(p => SvlmApi.resolveFileUrl(p.url)),
beds: l.beds || 0,
baths: l.baths || 0,
area: l.area ? Math.round(Number(l.area)) : 0,
floor: l.floor != null ? l.floor : null,
far: l.far != null ? Number(l.far) : null,
rentalTerm: l.rentalTerm || null,
leaseTerms: l.leaseTerms || null,
monthlyPrices: l.monthlyPrices || null,
tag: (sale != null && rent != null) ? 'both' : (rent != null ? 'rent' : 'sale'),
amenities: (l.amenities || []).map(a => lang === 'en' ? a.nameEn : a.nameTh).filter(Boolean),
agent: l.agent,
availability: l.availability,
availabilityStatus: l.availabilityStatus || null,
availabilityDate: l.availabilityDate || null,
wantsToSell: !!l.wantsToSell,
geo: (l.geo && l.geo.lat != null && l.geo.lng != null) ? { lat: Number(l.geo.lat), lng: Number(l.geo.lng) } : null,
// Owner-facing stats — viewsCount comes back on every listing response,
// leadsCount only on the "mine/:id" detail (undefined elsewhere, which
// is fine since only ownerMode ever renders it).
viewsCount: l.viewsCount != null ? l.viewsCount : null,
leadsCount: l.leadsCount != null ? l.leadsCount : null,
buildingName: l.buildingName || null,
};
}
function formatCustProjectReview(p, lang) {
const cover = (p.photos || [])[0];
return {
id: p.id,
name: (lang === 'en' ? p.name.en : p.name.th) || p.name.th || p.name.en || '',
loc: [p.district, p.province].filter(Boolean).join(', '),
price: p.startingPrice ? '฿' + Math.round(Number(p.startingPrice)).toLocaleString('en-US') : '',
coverUrl: cover ? SvlmApi.resolveFileUrl(cover.url) : null,
photos: (p.photos || []).slice().sort((a, b) => a.order - b.order).map(ph => SvlmApi.resolveFileUrl(ph.url)),
province: p.province,
district: p.district,
completionYear: p.completionYear,
floors: p.floors,
totalUnits: p.totalUnits,
nearbyLandmarks: p.nearbyLandmarks || [],
nearbyTransit: p.nearbyTransit || [],
facilities: p.facilities || [],
promotions: p.promotions || [],
unitTypes: p.unitTypes || [],
};
}
function estimateMortgage(priceValue) {
if (!priceValue) return null;
const loan = priceValue * 0.9;
const r = 0.065 / 12;
const n = 360;
const installment = (loan * r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1);
const minIncome = installment / 0.4;
const fmt = (v) => '฿' + Math.round(v).toLocaleString('en-US');
return { installment: fmt(installment), minIncome: fmt(minIncome) };
}
/* ---------------- Small shared building blocks ---------------- */
function Ph({ w, h, r = 16, circle, label, bg = '#F5EEE8', color = '#B3A89F' }) {
return (
{label}
);
}
function LangToggle({ lang, onToggle, dark }) {
return (
);
}
function BackBtn({ onClick, glass }) {
return (
);
}
/* ---------------- Onboarding ---------------- */
function CustOnboarding({ lang, step, setStep, onDone }) {
const ob = CUST_ONBOARD[step];
const isLast = step >= CUST_ONBOARD.length - 1;
const t = CUST_DICT;
return (
{CUST_ONBOARD.map((_, i) => (
))}
{ob.title[lang]}
{ob.sub[lang]}
isLast ? onDone() : setStep(step + 1)}>
{isLast ? t.onboard_getstarted[lang] : t.onboard_continue[lang]}
);
}
/* ---------------- Login ---------------- */
function BrandIconFacebook({ size = 28 }) {
return (
);
}
function BrandIconGoogle({ size = 28 }) {
return (
);
}
function BrandIconLine({ size = 28 }) {
return (
LINE
);
}
function BrandIconApple({ size = 24 }) {
return (
);
}
/** Pill-shaped sign-in row shared by every method on the login menu — real icon, centered label, optional dimmed/disabled state. */
function CustLoginPill({ icon, label, disabled, onClick }) {
return (
{icon}
{label}
);
}
function CustGoogleButton({ onCredential, variant, label }) {
const ref = React.useRef(null);
useEffect(() => {
let cancelled = false;
let attempts = 0;
function tryInit() {
if (cancelled) return;
const clientId = window.GOOGLE_LOGIN_CLIENT_ID;
if (!clientId || !window.google || !window.google.accounts || !window.google.accounts.id) {
if (attempts++ < 20) setTimeout(tryInit, 250);
return;
}
window.google.accounts.id.initialize({
client_id: clientId,
callback: (resp) => onCredential(resp && resp.credential),
});
if (ref.current) {
ref.current.innerHTML = '';
window.google.accounts.id.renderButton(ref.current, { type: 'icon', shape: 'circle', size: 'large' });
}
}
tryInit();
return () => { cancelled = true; };
}, []);
if (variant === 'bar') {
// Renders Google's real button off-screen (required — it's the only way to get a
// valid credential callback) and forwards a click on our custom pill to it, so the
// visible row can match every other method's look instead of Google's own widget style.
return (
} label={label} onClick={() => { const btn = ref.current && ref.current.querySelector('div[role="button"]'); if (btn) btn.click(); }} />
);
}
return
;
}
// Shared across every LINE button/auto-login check on the page — liff.init() must
// resolve exactly once before ANY liff.* call (isLoggedIn/login/getIDToken all throw
// otherwise), so every caller awaits this same promise instead of racing their own init.
let _liffInitPromise = null;
function ensureLiffInit() {
if (!window.LINE_LIFF_ID || !window.liff) return Promise.reject(new Error('LIFF SDK not available'));
if (!_liffInitPromise) _liffInitPromise = window.liff.init({ liffId: window.LINE_LIFF_ID });
return _liffInitPromise;
}
function CustLineButton({ onIdToken, disabled, variant, label, onError }) {
async function handleClick() {
if (disabled) return;
try {
await ensureLiffInit();
if (!window.liff.isLoggedIn()) {
window.liff.login();
return;
}
const idToken = window.liff.getIDToken();
if (idToken) onIdToken(idToken);
} catch (e) {
console.error('SVLM: LINE login failed', e);
if (onError) onError(e);
}
}
if (variant === 'bar') {
return } label={label} disabled={disabled} onClick={handleClick} />;
}
return (
L
);
}
let appleSdkPromise;
function loadAppleSdk() {
if (window.AppleID) return Promise.resolve();
if (!appleSdkPromise) appleSdkPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js';
script.onload = resolve;
script.onerror = () => { script.remove(); appleSdkPromise = null; reject(new Error('Apple SDK unavailable')); };
document.head.appendChild(script);
});
return appleSdkPromise;
}
function CustAppleButton({ label, disabled, onLogin, onError }) {
const [ready, setReady] = useState(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let active = true;
setReady(null);
SvlmApi.appleConfig().then(async config => {
if (!config.enabled || !active) return;
await loadAppleSdk();
const challenge = await SvlmApi.appleChallenge();
if (active) setReady({ config, challenge, expires: Date.now() + 290000 });
}).catch(() => { /* Remain disabled until configured and available. */ });
return () => { active = false; };
}, [attempt]);
async function signIn() {
if (!ready || disabled) return;
if (Date.now() >= ready.expires) {
setAttempt(v => v + 1);
onError();
return;
}
const { config, challenge } = ready;
setReady(null);
try {
// Initialize and open directly in the click handler to preserve popup activation.
window.AppleID.auth.init({ clientId: config.clientId, redirectURI: config.redirectURI,
scope: 'name email', state: challenge.challengeId, nonce: challenge.nonceHash, usePopup: true });
const response = await window.AppleID.auth.signIn();
if (response.authorization?.state !== challenge.challengeId) throw new Error('Invalid state');
const name = [response.user?.name?.firstName, response.user?.name?.lastName]
.filter(v => typeof v === 'string').join(' ').trim().slice(0, 100) || undefined;
await SvlmApi.customerLoginApple(response.authorization.id_token, challenge.challengeId, name);
onLogin();
} catch (err) {
if (err?.error !== 'popup_closed_by_user' && err?.error !== 'user_cancelled_authorize') onError();
setAttempt(v => v + 1);
}
}
return } label={label} disabled={disabled || !ready} onClick={signIn} />;
}
let facebookSdkPromise;
function loadFacebookSdk(appId) {
if (window.FB) return Promise.resolve(window.FB);
if (!facebookSdkPromise) facebookSdkPromise = new Promise((resolve, reject) => {
window.fbAsyncInit = () => {
window.FB.init({ appId, cookie: true, xfbml: false, version: 'v19.0' });
resolve(window.FB);
};
const script = document.createElement('script');
script.src = 'https://connect.facebook.net/en_US/sdk.js';
script.onerror = () => { script.remove(); facebookSdkPromise = null; reject(new Error('Facebook SDK unavailable')); };
document.head.appendChild(script);
});
return facebookSdkPromise;
}
// Facebook has no ID-token concept — FB.login() hands back a short-lived user
// access token, which the backend verifies live against Facebook's own APIs
// (see AuthService.loginWithFacebook), not a signature we check here.
function CustFacebookButton({ label, disabled, onLogin, onError }) {
const [ready, setReady] = useState(false);
useEffect(() => {
let active = true;
SvlmApi.facebookConfig().then(async config => {
if (!config.enabled || !active) return;
await loadFacebookSdk(config.appId);
if (active) setReady(true);
}).catch(() => { /* Remain disabled until configured and available. */ });
return () => { active = false; };
}, []);
function signIn() {
if (!ready || disabled) return;
window.FB.login(response => {
const accessToken = response?.authResponse?.accessToken;
if (!accessToken) {
if (response?.status !== 'unknown') onError();
return;
}
onLogin(accessToken);
}, { scope: 'public_profile,email' });
}
return } label={label} disabled={disabled || !ready} onClick={signIn} />;
}
function CustLogin({ lang, onToggleLang, onLogin }) {
const t = CUST_DICT;
const [view, setView] = useState('menu'); // 'menu' | 'signin' | 'signup'
const [agreed, setAgreed] = useState(true);
const [showTermsModal, setShowTermsModal] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [remember, setRemember] = useState(true);
const [step, setStep] = useState('phone'); // signup OTP step: 'phone' | 'code'
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [devHint, setDevHint] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
// LIFF login is a full-page redirect — on the bounce back, liff.init()
// resolves already logged in, so finish the login automatically instead
// of making the customer press the LINE button a second time.
useEffect(() => {
let cancelled = false;
ensureLiffInit().then(() => {
if (cancelled || !window.liff.isLoggedIn()) return;
const idToken = window.liff.getIDToken();
if (idToken) submitLine(idToken);
}).catch(e => {
if (cancelled) return;
console.error('SVLM: LIFF init failed', e);
setError(e && e.message ? `LIFF init: ${e.message}` : t.login_error[lang]);
});
return () => { cancelled = true; };
}, []);
async function submitPhone() {
if (!phone.trim() || busy) return;
setError('');
setBusy(true);
try {
const res = await SvlmApi.requestOtp(phone.trim());
setDevHint(res && res.devCode ? ('Dev code: ' + res.devCode) : '');
setStep('code');
} catch (err) {
setError(t.login_error[lang]);
} finally {
setBusy(false);
}
}
async function submitCode() {
if (!code.trim() || busy) return;
setError('');
setBusy(true);
try {
await SvlmApi.customerVerifyOtp(phone.trim(), code.trim());
onLogin();
} catch (err) {
setError(t.login_error[lang]);
} finally {
setBusy(false);
}
}
// "Mobile No. or email" on the sign-in card — the backend only accepts a
// phone for password login today, so this submits the field as-is; a real
// email typed here fails the same way it would against the API directly,
// rather than silently pretending email login works.
async function submitPassword() {
if (!phone.trim() || !password.trim() || busy) return;
setError('');
setBusy(true);
try {
await SvlmApi.customerLoginPassword(phone.trim(), password);
onLogin();
} catch (err) {
setError((err && err.message) || t.login_error[lang]);
} finally {
setBusy(false);
}
}
async function submitGoogle(idToken) {
if (!idToken || busy) return;
setError('');
setBusy(true);
try {
await SvlmApi.customerLoginGoogle(idToken);
onLogin();
} catch (err) {
setError(t.login_error[lang]);
} finally {
setBusy(false);
}
}
async function submitLine(idToken) {
if (!idToken || busy) return;
setError('');
setBusy(true);
try {
await SvlmApi.customerLoginLine(idToken);
onLogin();
} catch (err) {
setError((err && err.message) || t.login_error[lang]);
} finally {
setBusy(false);
}
}
async function submitFacebook(accessToken) {
if (!accessToken || busy) return;
setError('');
setBusy(true);
try {
await SvlmApi.customerLoginFacebook(accessToken);
onLogin();
} catch (err) {
setError(t.login_error[lang]);
} finally {
setBusy(false);
}
}
const TermsModal = () => (
!showTermsModal ? null : (
{t.terms_modal_title[lang]}
{t.terms_modal_body[lang]} {t.login_terms_service[lang]} , {t.login_terms_privacy[lang]} .
setShowTermsModal(false)} style={{ flex: 1, padding: '12px 0', borderRadius: 14, background: '#F1E6DC', color: '#7A6F68', fontWeight: 700, fontSize: 13, cursor: 'pointer' }}>{t.terms_modal_cancel[lang]}
{ setAgreed(true); setShowTermsModal(false); }} style={{ flex: 1, padding: '12px 0', borderRadius: 14, background: '#FF6A1A', color: '#fff', fontWeight: 700, fontSize: 13, cursor: 'pointer' }}>{t.terms_modal_agree[lang]}
)
);
const BackCircle = ({ onClick }) => (
);
if (view === 'menu') {
return (
{t.login_welcome_title[lang]}
{t.login_welcome_tagline[lang]}
setError(t.login_error[lang])} />
setError((e && e.message) ? `LIFF: ${e.message}` : t.login_error[lang])} />
setError(t.login_error[lang])} />
} label={t.login_social_email[lang]} onClick={() => { setError(''); setView('signin'); }} />
{!!error &&
{error}
}
);
}
if (view === 'signin') {
const canSubmit = phone.trim() && password.trim() && !busy;
return (
setView('menu')} />
{t.login_signin_title[lang]}
setRemember(e.target.checked)} style={{ accentColor: '#FF6A1A' }} />
{t.login_remember[lang]}
{!!error &&
{error}
}
{busy ? '…' : t.login_log_in_btn[lang]}
{t.login_forgot_password[lang]}
{ setError(''); setStep('phone'); setCode(''); setView('signup'); }} style={{ color: '#FF6A1A', fontWeight: 700, cursor: 'pointer' }}>{t.login_register_link[lang]}
);
}
// view === 'signup' — reuses the same phone+OTP endpoints as the rest of the
// app; verifying a new number here logs the customer straight in, same as
// "Sign in" would for an existing one, matching the reference's single Next step.
return (
{ setStep('phone'); setCode(''); setError(''); setView('menu'); }} />
{t.login_signup_title[lang]}
{!!devHint &&
{devHint}
}
{!!error &&
{error}
}
{busy ? '…' : t.login_next_btn[lang]}
{t.login_otp_help[lang]}
{ setStep('phone'); setCode(''); setError(''); setView('signin'); }} style={{ fontSize: 12, color: '#FF6A1A', fontWeight: 700, cursor: 'pointer' }}>{t.login_switch_methods[lang]}
);
}
/* ---------------- Header banner (Ops-managed, up to 5, 1774x887) ---------------- */
function CustHeaderBanner({ banners }) {
const [index, setIndex] = useState(0);
useEffect(() => {
if (banners.length < 2) return;
const id = setInterval(() => setIndex(i => (i + 1) % banners.length), 4500);
return () => clearInterval(id);
}, [banners.length]);
if (!banners.length) return null;
const b = banners[index] || banners[0];
return (
// height:0 + paddingBottom:'50%' (the classic pre-aspect-ratio hack) instead of
// aspectRatio: '2/1' directly — this box's only in-flow content is position:absolute
// children, so as a flex item inside a column flex container it has no intrinsic
// content height for aspect-ratio to resolve against and collapses to 0px tall,
// silently hiding the banner even though the promotion data loads correctly.
{(b.title || b.subtitle) && (
{!!b.title &&
{b.title}
}
{!!b.subtitle &&
{b.subtitle}
}
)}
{banners.length > 1 && (
{banners.map((_, i) => (
))}
)}
);
}
/* ---------------- Home ---------------- */
function CustHome({ lang, onToggleLang, homeMode, setHomeMode, goShortTerm, properties, hotDeals, recommend, featuredProperty, promotions, projectReviews, openProperty, openProjectReview, openListProperty, openGeneralInquire, openInsurance, goSearch, goSearchMap, openLocationSearch, openCategorySearch }) {
const t = CUST_DICT;
const headerBanners = (promotions || []).filter(p => p.type === 'header');
const dealPromotions = (promotions || []).filter(p => p.type !== 'header');
const modes = [
{ key: 'buy', label: t.mode_buy[lang], go: () => setHomeMode('buy') },
{ key: 'rent', label: t.mode_rent[lang], go: () => setHomeMode('rent') },
{ key: 'shortterm', label: t.mode_shortterm[lang], go: goShortTerm },
];
const quickRegions = CUST_PRIME_LOCATIONS;
return (
{t.home_hero_welcome[lang]}
{t.home_hero_title[lang]}
{quickRegions.map((g, i) => (
openLocationSearch(g.region.en)} style={{ flexShrink: 0, background: 'rgba(255,255,255,.2)', border: '1px solid rgba(255,255,255,.3)', padding: '7px 14px', borderRadius: 999, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>{g.region[lang]}
))}
{modes.map(m => (
{m.label}
))}
{CUST_HOME_CATEGORIES.map((c, i) => (
openCategorySearch(c.type)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
{c.label[lang]}
))}
{hotDeals.length > 0 && (
{t.sec_nearby[lang]}
{t.see_all[lang]}
{hotDeals.map(p => (
openProperty(p.id)} style={{ flexShrink: 0, width: 220, borderRadius: 18, border: '1px solid #F1E6DC', overflow: 'hidden', cursor: 'pointer' }}>
{p.coverUrl ?
:
}
{CUST_TAGS[p.tag] &&
{CUST_TAGS[p.tag].label[lang]}
}
{!!custAvailabilityBadge(p, lang) &&
{custAvailabilityBadge(p, lang).label}
}
{p.name}
{p.loc} · {p.beds} {t.beds_label[lang]} · {p.baths} {t.baths_label[lang]}
{p.price}
))}
)}
{dealPromotions.length > 0 && (
{dealPromotions.map(promo => (
{promo.title}
{!!promo.subtitle &&
{promo.subtitle}
}
))}
)}
{featuredProperty && (
openProperty(featuredProperty.id)} style={{ position: 'relative', height: 170, borderRadius: 18, overflow: 'hidden', cursor: 'pointer', background: featuredProperty.coverUrl ? `center/cover url(${featuredProperty.coverUrl})` : '#201812' }}>
{t.featured_badge[lang]}
{featuredProperty.name}
{featuredProperty.loc}
{featuredProperty.price}
)}
{t.list_banner_title[lang]}
{t.list_banner_sub[lang]}
{t.list_banner_cta[lang]}
{t.gen_inq_banner_title[lang]}
{t.gen_inq_banner_sub[lang]}
{t.gen_inq_banner_cta[lang]}
{t.sec_hotdeals[lang]}
{t.see_all[lang]}
{hotDeals.map(p => (
openProperty(p.id)} style={{ flexShrink: 0, width: 168, cursor: 'pointer' }}>
{p.coverUrl ?
:
}
{CUST_TAGS[p.tag] &&
{CUST_TAGS[p.tag].label[lang]}
}
{!!custAvailabilityBadge(p, lang) &&
{custAvailabilityBadge(p, lang).label}
}
{p.name}
{p.loc}
{p.area} {t.area_label[lang]} · {p.beds} {t.beds_label[lang]} · {p.baths} {t.baths_label[lang]}{p.floor != null ? ' · ' + t.floor_label[lang] + ' ' + p.floor : ''}
{p.price}
))}
{t.sec_recommend[lang]}
{t.see_all[lang]}
{recommend.map(p => (
openProperty(p.id)} style={{ flexShrink: 0, width: 168, cursor: 'pointer' }}>
{p.coverUrl ?
:
}
{CUST_TAGS[p.tag] &&
{CUST_TAGS[p.tag].label[lang]}
}
{!!custAvailabilityBadge(p, lang) &&
{custAvailabilityBadge(p, lang).label}
}
{p.name}
{p.loc}
{p.area} {t.area_label[lang]} · {p.beds} {t.beds_label[lang]} · {p.baths} {t.baths_label[lang]}{p.floor != null ? ' · ' + t.floor_label[lang] + ' ' + p.floor : ''}
{p.price}
))}
{projectReviews && projectReviews.length > 0 && (
{t.sec_project_reviews[lang]}
{projectReviews.map(p => (
openProjectReview(p.id)} style={{ flexShrink: 0, width: 168, cursor: 'pointer' }}>
{p.coverUrl ?
:
}
{p.name}
{p.loc}
{!!p.price &&
{p.price}
}
))}
)}
{t.sec_new_projects[lang]}
{t.see_all[lang]}
{CUST_HOME_NEW_PROJECTS.map((p, i) => (
{t.new_projects_tag[lang]}
{p.type[lang]}
{p.name}
{p.loc[lang]}
{t.pr_starting_price[lang]} {p.startingPrice}
))}
{t.sec_recommend_developers[lang]}
{CUST_HOME_DEVELOPERS.map((d, i) => (
))}
{t.search_province_eyebrow[lang]}
{t.search_province_title[lang]}
{CUST_HOME_PROVINCES.map((p, i) => (
))}
{CUST_HOME_TOP_LOCATIONS_BY_REGION.map((group, gi) => (
{t.top_locations_prefix[lang]} {group.region[lang]}
{t.top_locations_sub_prefix[lang]} {group.region[lang]}{t.top_locations_sub_suffix[lang]}
{group.areas.map((l, i) => (
openLocationSearch(l.name)} style={{ flexShrink: 0, width: 130, height: 100, position: 'relative', borderRadius: 14, overflow: 'hidden', cursor: 'pointer' }}>
))}
))}
{t.sec_blog_review[lang]}
{t.blog_review_sub[lang]}
{CUST_HOME_BLOG.featured.tag[lang]}
{CUST_HOME_BLOG.featured.title[lang]}
{CUST_HOME_BLOG.featured.author}
{t.discover_more_articles[lang]}
{CUST_HOME_BLOG.list.map((a, i) => (
{a.category[lang]}
{a.title[lang]}
{a.date}
))}
{t.insurance_banner_title[lang]}
{t.insurance_banner_sub[lang]}
{t.insurance_banner_cta[lang]}
{t.sec_shortterm[lang]}
{t.see_all[lang]}
{CUST_SHORT_TERM_RAW.slice(0, 3).map(raw => {
const p = { id: raw.id, name: raw.name[lang], loc: raw.loc[lang], price: raw.price };
return (
{p.name}
{p.loc}
{p.price}
);
})}
{t.sec_recommended[lang]}
{properties.map(p => (
openProperty(p.id)} style={{ display: 'flex', gap: 12, cursor: 'pointer' }}>
{p.coverUrl ?
:
}
{p.name}
{p.loc}
{p.beds} {t.beds_label[lang]} · {p.baths} {t.baths_label[lang]} · {p.area} {t.area_label[lang]}{p.floor != null ? ' · ' + t.floor_label[lang] + ' ' + p.floor : ''}
{p.price}
))}
);
}
/** Newsletter opt-in — the checkbox itself is the consent event for the new marketing_contacts list (see MarketingContactsService.subscribe on the backend). Deliberately separate from CustProfile's pushEnabled/promoEnabled toggles, which are local device push preferences, not this consent-gated email list. */
function CustNewsletterSignup({ lang }) {
const t = CUST_DICT;
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [consent, setConsent] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState(null); // 'ok' | 'error' | null
async function submit() {
if (!email.trim() || !consent || submitting) return;
setSubmitting(true);
setResult(null);
try {
await SvlmApi.subscribeMarketingContact(email.trim(), name.trim() || undefined);
trackPixel('Lead', { content_name: 'newsletter_signup' });
setResult('ok');
setEmail('');
setName('');
setConsent(false);
} catch (err) {
console.error('SVLM: newsletter signup failed', err);
setResult('error');
} finally {
setSubmitting(false);
}
}
return (
{t.newsletter_title[lang]}
{t.newsletter_subtitle[lang]}
{result === 'ok' ? (
{t.newsletter_subscribed[lang]}
) : (
setEmail(e.target.value)} placeholder={t.newsletter_email_ph[lang]}
style={{ height: 42, borderRadius: 10, border: '1px solid #EDE4DC', padding: '0 12px', fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box' }} />
setName(e.target.value)} placeholder={t.newsletter_name_ph[lang]}
style={{ height: 42, borderRadius: 10, border: '1px solid #EDE4DC', padding: '0 12px', fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box' }} />
setConsent(c => !c)} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer' }}>
{consent && ✓ }
{t.newsletter_consent[lang]}
{result === 'error' && {t.newsletter_failed[lang]}
}
{submitting ? t.newsletter_submitting[lang] : t.newsletter_submit[lang]}
)}
);
}
/* ---------------- Short-term ---------------- */
function CustShortTerm({ lang, goHome, openProperty, listings, loading }) {
const t = CUST_DICT;
const stays = (listings || []).map(l => {
const rates = Object.values(l.monthlyPrices || {}).map(Number).filter(n => n > 0);
const fromRate = rates.length ? Math.min(...rates) : null;
return { id: l.id, name: l.name, loc: l.loc, coverUrl: l.coverUrl, beds: l.beds, baths: l.baths, area: l.area, floor: l.floor, fromRate };
});
return (
{t.shortterm_title[lang]}
{t.shortterm_sub[lang]}
{loading ? (
…
) : stays.length === 0 ? (
{t.shortterm_empty[lang]}
) : (
{stays.map(p => (
openProperty(p.id)} style={{ display: 'flex', gap: 12, cursor: 'pointer' }}>
{p.coverUrl ?
:
}
{p.name}
{p.loc}
{p.beds} {t.beds_label[lang]} · {p.baths} {t.baths_label[lang]} · {p.area} {t.area_label[lang]}{p.floor != null ? ' · ' + t.floor_label[lang] + ' ' + p.floor : ''}
{p.fromRate != null &&
{bookingFormatThb(p.fromRate)}/{t.booking_month[lang]}
}
))}
)}
);
}
/* ---------------- Search (list + map) ---------------- */
function CustSearch({ lang, properties, propsTotal, loadingMoreProps, onLoadMore, searchMode, setSearchMode, openFilters, selectedLocation, onSelectLocation, filterDeal, setFilterDeal, openProperty, query, onQueryChange }) {
const t = CUST_DICT;
return (
{[
{ key: '', label: t.filter_pet_any[lang] },
{ key: 'sale', label: t.list_type_sale[lang] },
{ key: 'rent', label: t.list_type_rent[lang] },
].map(d => (
setFilterDeal(d.key)}
className="cust-chip"
style={{ flexShrink: 0, padding: '8px 14px', background: filterDeal === d.key ? '#201812' : '#F5EEE8', color: filterDeal === d.key ? '#fff' : '#7A6F68' }}
>{d.label}
))}
{CUST_FEATURED_LOCATIONS.map((fl, i) => (
onSelectLocation(fl[lang])}
className="cust-chip"
style={{ flexShrink: 0, padding: '8px 14px', background: selectedLocation === fl[lang] ? '#FF6A1A' : '#FFF1E6', color: selectedLocation === fl[lang] ? '#fff' : '#B3634A' }}
>{fl[lang]}
))}
setSearchMode('list')} style={{ flex: 1, textAlign: 'center', padding: '9px 0', borderRadius: 11, fontSize: 12.5, fontWeight: 700, background: searchMode === 'list' ? '#fff' : 'transparent', color: searchMode === 'list' ? '#201812' : '#8A7C74', cursor: 'pointer' }}>{t.search_list[lang]}
setSearchMode('map')} style={{ flex: 1, textAlign: 'center', padding: '9px 0', borderRadius: 11, fontSize: 12.5, fontWeight: 700, background: searchMode === 'map' ? '#fff' : 'transparent', color: searchMode === 'map' ? '#201812' : '#8A7C74', cursor: 'pointer' }}>{t.search_map[lang]}
{searchMode === 'list' ? (
{propsTotal > 0 && (
{t.results_count[lang].replace('{shown}', properties.length).replace('{total}', propsTotal)}
)}
{properties.map(p => (
openProperty(p.id)} style={{ display: 'flex', gap: 12, cursor: 'pointer' }}>
{p.coverUrl ?
:
}
{p.name}
{p.loc}
{p.beds} {t.beds_label[lang]} · {p.baths} {t.baths_label[lang]} · {p.area} {t.area_label[lang]}{p.floor != null ? ' · ' + t.floor_label[lang] + ' ' + p.floor : ''}
{p.price}
{!!custAvailabilityBadge(p, lang) &&
{custAvailabilityBadge(p, lang).label}
}
))}
{properties.length < propsTotal && (
{loadingMoreProps ? t.loading_more[lang] : t.load_more[lang]}
)}
) : (
({ id: p.id, lat: p.geo && p.geo.lat, lng: p.geo && p.geo.lng, label: p.name + ' — ' + p.price }))}
onPinClick={openProperty}
emptyLabel={t.map_no_positions[lang]}
/>
)}
);
}
const PUB_INQUIRY_LANGUAGES = [
{ value: 'en', label: 'English' },
{ value: 'th', label: 'ไทย / Thai' },
{ value: 'zh', label: '中文 / Chinese' },
{ value: 'ja', label: '日本語 / Japanese' },
{ value: 'fr', label: 'Français / French' },
];
// Phone country codes for the public inquiry widget — Thailand first as the
// default, since SVLM's own listings are all in Thailand, then the rest
// alphabetized by country name so a long list stays scannable.
const PHONE_COUNTRY_CODES = [
{ iso: 'TH', flag: '🇹🇭', dial: '+66', name: 'Thailand' },
{ iso: 'AU', flag: '🇦🇺', dial: '+61', name: 'Australia' },
{ iso: 'CA', flag: '🇨🇦', dial: '+1', name: 'Canada' },
{ iso: 'CN', flag: '🇨🇳', dial: '+86', name: 'China' },
{ iso: 'FR', flag: '🇫🇷', dial: '+33', name: 'France' },
{ iso: 'DE', flag: '🇩🇪', dial: '+49', name: 'Germany' },
{ iso: 'HK', flag: '🇭🇰', dial: '+852', name: 'Hong Kong' },
{ iso: 'IN', flag: '🇮🇳', dial: '+91', name: 'India' },
{ iso: 'ID', flag: '🇮🇩', dial: '+62', name: 'Indonesia' },
{ iso: 'JP', flag: '🇯🇵', dial: '+81', name: 'Japan' },
{ iso: 'KR', flag: '🇰🇷', dial: '+82', name: 'South Korea' },
{ iso: 'LA', flag: '🇱🇦', dial: '+856', name: 'Laos' },
{ iso: 'MY', flag: '🇲🇾', dial: '+60', name: 'Malaysia' },
{ iso: 'MM', flag: '🇲🇲', dial: '+95', name: 'Myanmar' },
{ iso: 'NL', flag: '🇳🇱', dial: '+31', name: 'Netherlands' },
{ iso: 'NZ', flag: '🇳🇿', dial: '+64', name: 'New Zealand' },
{ iso: 'PH', flag: '🇵🇭', dial: '+63', name: 'Philippines' },
{ iso: 'RU', flag: '🇷🇺', dial: '+7', name: 'Russia' },
{ iso: 'SG', flag: '🇸🇬', dial: '+65', name: 'Singapore' },
{ iso: 'GB', flag: '🇬🇧', dial: '+44', name: 'United Kingdom' },
{ iso: 'US', flag: '🇺🇸', dial: '+1', name: 'United States' },
{ iso: 'VN', flag: '🇻🇳', dial: '+84', name: 'Vietnam' },
];
// A leading-icon input row — same "cust-input" field, just with room carved
// out on the left for an icon, matching the reference layout's icon-prefixed
// fields (person/phone/envelope) while keeping SVLM's own colors.
function CustIconInput({ icon, ...inputProps }) {
return (
);
}
// A static, always-visible card on the property detail page — not a modal —
// for every visitor, logged in or not. Name/phone/email icons and the +66
// country prefix mirror the reference layout; colors stay SVLM's own
// orange/cream so it still matches the rest of the app. Signed-in visitors
// get an extra "Tell us more" section with the fuller CRM qualification
// fields the old modal used to collect.
function CustPublicInquiryForm({ lang, property, loggedIn }) {
const t = CUST_DICT;
const isSale = property.deal === 'sale';
const [mode, setMode] = useState('details'); // 'details' | 'viewing'
const [name, setName] = useState('');
const [phoneCountry, setPhoneCountry] = useState('TH');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [inquirerType, setInquirerType] = useState('personal');
const [language, setLanguage] = useState(lang === 'th' ? 'th' : 'en');
const [message, setMessage] = useState(t.pub_inquiry_default_message[lang].replace('{property}', property.name || ''));
const [viewingDate, setViewingDate] = useState('');
const [viewingTime, setViewingTime] = useState('');
const [coAgentName, setCoAgentName] = useState('');
const [coAgentEmail, setCoAgentEmail] = useState('');
const [coAgentPhone, setCoAgentPhone] = useState('');
const [coAgentMessage, setCoAgentMessage] = useState('');
// "Tell us more" fields — only collected (and only shown) once the visitor
// is signed in, same qualification data the old logged-in-only modal used.
const [nationality, setNationality] = useState('');
const [occupation, setOccupation] = useState('');
const [jobPosition, setJobPosition] = useState('');
const [budget, setBudget] = useState('');
const [contractDuration, setContractDuration] = useState('');
const [visaType, setVisaType] = useState('');
const [moveInDate, setMoveInDate] = useState('');
const [livingMembers, setLivingMembers] = useState('');
const [gender, setGender] = useState(null);
const [wantsParking, setWantsParking] = useState(false);
const [smoking, setSmoking] = useState(false);
const [hasPets, setHasPets] = useState(false);
const [petDetails, setPetDetails] = useState('');
const [purchasePurpose, setPurchasePurpose] = useState(null);
const [paymentMethod, setPaymentMethod] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const [step, setStep] = useState(1); // 1: contact info, 2: qualification + message + submit
const coAgentMode = inquirerType === 'co_agent';
const canGoNext = name.trim() && phone.trim() && (!coAgentMode || coAgentName.trim());
const canSubmit = canGoNext;
// Signed-in visitors already have an account — prefill from it instead of
// asking them to retype what SVLM already knows.
useEffect(() => {
if (!loggedIn) return;
let active = true;
SvlmApi.fetchMeAsCustomer().then(me => {
if (!active) return;
if (me.name) setName(me.name);
if (me.phone) setPhone(me.phone);
if (me.email) setEmail(me.email);
}).catch(() => {});
return () => { active = false; };
}, [loggedIn]);
async function submit() {
if (submitting || !canSubmit) return;
setSubmitting(true);
setError('');
try {
const dialCode = (PHONE_COUNTRY_CODES.find(c => c.iso === phoneCountry) || PHONE_COUNTRY_CODES[0]).dial;
await SvlmApi.submitPublicInquiry({
listingId: property.id,
name: name.trim(),
phone: `${dialCode} ${phone.trim()}`,
email: email.trim() || undefined,
inquirerType,
language,
message: message.trim() || undefined,
mode,
viewingDate: mode === 'viewing' ? (viewingDate || undefined) : undefined,
viewingTime: mode === 'viewing' ? (viewingTime || undefined) : undefined,
...(coAgentMode ? {
coAgentName: coAgentName.trim(),
coAgentEmail: coAgentEmail.trim() || undefined,
coAgentPhone: coAgentPhone.trim() || undefined,
coAgentMessage: coAgentMessage.trim() || undefined,
} : {}),
...(loggedIn ? {
nationality: nationality.trim() || undefined,
occupation: occupation.trim() || undefined,
jobPosition: jobPosition.trim() || undefined,
budget: budget ? Number(budget) : undefined,
hasPets,
petDetails: (hasPets && !isSale) ? (petDetails.trim() || undefined) : undefined,
contractDuration: !isSale ? (contractDuration.trim() || undefined) : undefined,
visaType: !isSale ? (visaType.trim() || undefined) : undefined,
moveInDate: !isSale ? (moveInDate || undefined) : undefined,
livingMembers: (!isSale && livingMembers) ? Number(livingMembers) : undefined,
gender: !isSale ? (gender || undefined) : undefined,
wantsParking: !isSale ? wantsParking : undefined,
smoking: !isSale ? smoking : undefined,
purchasePurpose: isSale ? (purchasePurpose || undefined) : undefined,
paymentMethod: isSale ? (paymentMethod || undefined) : undefined,
} : {}),
});
trackPixel(mode === 'viewing' ? 'Schedule' : 'Lead', { content_ids: [property.id], content_type: 'product' });
setDone(true);
} catch (err) {
setError(t.inquire_error[lang]);
} finally {
setSubmitting(false);
}
}
return (
{t.pub_inquiry_heading[lang]}
{done ? (
{t.inquire_success[lang]}
) : step === 1 ? (
setMode('details')} style={{ flex: 1, textAlign: 'center', padding: '10px 0', borderRadius: 999, fontWeight: 700, fontSize: 13, cursor: 'pointer', background: mode === 'details' ? 'var(--c-orange)' : 'var(--c-soft-bg)', color: mode === 'details' ? '#fff' : 'var(--c-muted)' }}>{t.pub_inquiry_tab_details[lang]}
setMode('viewing')} style={{ flex: 1, textAlign: 'center', padding: '10px 0', borderRadius: 999, fontWeight: 700, fontSize: 13, cursor: 'pointer', background: mode === 'viewing' ? 'var(--c-orange)' : 'var(--c-soft-bg)', color: mode === 'viewing' ? '#fff' : 'var(--c-muted)' }}>{t.pub_inquiry_tab_viewing[lang]}
setName(e.target.value)} />
setPhoneCountry(e.target.value)}
style={{ width: 92, height: 52, borderRadius: 14, border: '1.5px solid var(--c-border)', background: '#fff', textAlign: 'center', fontSize: 13, fontWeight: 700, color: 'var(--c-ink)', flexShrink: 0, fontFamily: 'inherit', outline: 'none' }}
>
{PHONE_COUNTRY_CODES.map(c => {c.flag} {c.dial} )}
setPhone(e.target.value)} />
setEmail(e.target.value)} />
{mode === 'viewing' && (
)}
{t.pub_inquiry_type_label[lang]}
setInquirerType('personal')} />
setInquirerType('co_agent')} />
{coAgentMode && (
{t.coagent_section_title[lang]}
{t.coagent_sub[lang]}
{t.coagent_message_label[lang]}
)}
setStep(2)} disabled={!canGoNext}>
{t.pub_inquiry_next[lang]}
) : (
setStep(1)} style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', color: 'var(--c-muted)', fontSize: 12.5, fontWeight: 700, marginTop: -6 }}>
{t.pub_inquiry_back[lang]}
{loggedIn && (
{t.pub_inquiry_more_title[lang]}
{!isSale && (
{t.inquire_gender_label[lang]}
setGender(gender === 'male' ? null : 'male')} />
setGender(gender === 'female' ? null : 'female')} />
setGender(gender === 'other' ? null : 'other')} />
setWantsParking(v => !v)} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
{wantsParking ? '✓' : ''}
{t.inquire_parking_label[lang]}
setSmoking(v => !v)} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
{smoking ? '✓' : ''}
{t.inquire_smoking_label[lang]}
setHasPets(v => !v)} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
{hasPets ? '✓' : ''}
{t.inquire_has_pets_label[lang]}
{hasPets && (
setPetDetails(e.target.value)} placeholder={t.inquire_pet_details_ph[lang]} />
)}
)}
{isSale && (
{t.pr_lead_purpose[lang]}
setPurchasePurpose(purchasePurpose === 'self_use' ? null : 'self_use')} />
setPurchasePurpose(purchasePurpose === 'investment' ? null : 'investment')} />
{t.pr_lead_payment[lang]}
setPaymentMethod(paymentMethod === 'loan' ? null : 'loan')} />
setPaymentMethod(paymentMethod === 'cash' ? null : 'cash')} />
setHasPets(v => !v)} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
{hasPets ? '✓' : ''}
{t.inquire_pet_friendly_label[lang]}
)}
)}
setLanguage(e.target.value)}>
{PUB_INQUIRY_LANGUAGES.map(o => {o.label} )}
{t.pub_inquiry_message_label[lang]}
{t.pub_inquiry_consent_prefix[lang]} {t.login_terms_service[lang]} {t.login_terms_and[lang]} window.open('https://apphome.svlm.co.th/privacy.html', '_blank')} style={{ color: 'var(--c-orange)', textDecoration: 'underline', cursor: 'pointer' }}>{t.login_terms_privacy[lang]} .
{!!error && {error}
}
{submitting ? t.inquire_submitting[lang] : (mode === 'viewing' ? t.pub_inquiry_submit_viewing[lang] : t.pub_inquiry_submit_details[lang])}
)}
);
}
const GEN_INQ_BEDS_OPTIONS = [0, 1, 2, 3];
function CustGeneralInquireModal({ lang, onClose }) {
const t = CUST_DICT;
const [dealType, setDealType] = useState(null);
const [province, setProvince] = useState('');
const [budget, setBudget] = useState('');
const [specificProject, setSpecificProject] = useState('');
const [beds, setBeds] = useState(null);
const [desiredUnitSize, setDesiredUnitSize] = useState('');
const [nationality, setNationality] = useState('');
const [occupation, setOccupation] = useState('');
const [jobPosition, setJobPosition] = useState('');
const [contractDuration, setContractDuration] = useState('');
const [visaType, setVisaType] = useState('');
const [moveInDate, setMoveInDate] = useState('');
const [livingMembers, setLivingMembers] = useState('');
const [gender, setGender] = useState(null);
const [wantsParking, setWantsParking] = useState(false);
const [smoking, setSmoking] = useState(false);
const [hasPets, setHasPets] = useState(false);
const [petDetails, setPetDetails] = useState('');
const [purchasePurpose, setPurchasePurpose] = useState(null);
const [paymentMethod, setPaymentMethod] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const loggedIn = SvlmApi.isCustomerLoggedIn();
const isSale = dealType === 'sale';
const canSubmit = dealType && province && budget;
async function submit() {
if (!canSubmit || submitting) return;
setSubmitting(true);
setError('');
try {
await SvlmApi.submitGeneralInquiry({
dealType,
province: provinceToThai(province),
budget: Number(budget),
specificProject: specificProject.trim() || undefined,
beds: beds === null ? undefined : beds,
desiredUnitSize: desiredUnitSize.trim() || undefined,
nationality: nationality.trim() || undefined,
occupation: occupation.trim() || undefined,
jobPosition: jobPosition.trim() || undefined,
hasPets,
petDetails: (hasPets && !isSale) ? (petDetails.trim() || undefined) : undefined,
contractDuration: !isSale ? (contractDuration.trim() || undefined) : undefined,
visaType: !isSale ? (visaType.trim() || undefined) : undefined,
moveInDate: !isSale ? (moveInDate || undefined) : undefined,
livingMembers: (!isSale && livingMembers) ? Number(livingMembers) : undefined,
gender: !isSale ? (gender || undefined) : undefined,
wantsParking: !isSale ? wantsParking : undefined,
smoking: !isSale ? smoking : undefined,
purchasePurpose: isSale ? (purchasePurpose || undefined) : undefined,
paymentMethod: isSale ? (paymentMethod || undefined) : undefined,
});
setDone(true);
} catch (err) {
setError(t.inquire_error[lang]);
} finally {
setSubmitting(false);
}
}
return (
{t.gen_inq_title[lang]}
✕
{done ? (
{t.gen_inq_success[lang]}
) : !loggedIn ? (
{t.gen_inq_login_required[lang]}
) : (
{t.gen_inq_deal_label[lang]}
setDealType(dealType === 'rent' ? null : 'rent')} />
setDealType(dealType === 'sale' ? null : 'sale')} />
{t.gen_inq_province_label[lang]}
setProvince(e.target.value)}>
{t.gen_inq_province_ph[lang]}
{CUST_PROVINCES_77.map(p => {lang === 'en' ? p.en : p.th} )}
{t.gen_inq_beds_label[lang]}
{GEN_INQ_BEDS_OPTIONS.map(b => (
setBeds(beds === b ? null : b)} />
))}
{dealType === 'rent' && (
{t.inquire_gender_label[lang]}
setGender(gender === 'male' ? null : 'male')} />
setGender(gender === 'female' ? null : 'female')} />
setGender(gender === 'other' ? null : 'other')} />
setWantsParking(v => !v)} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
{wantsParking ? '✓' : ''}
{t.inquire_parking_label[lang]}
setSmoking(v => !v)} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
{smoking ? '✓' : ''}
{t.inquire_smoking_label[lang]}
setHasPets(v => !v)} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
{hasPets ? '✓' : ''}
{t.inquire_has_pets_label[lang]}
{hasPets && (
setPetDetails(e.target.value)} placeholder={t.inquire_pet_details_ph[lang]} />
)}
)}
{isSale && (
{t.pr_lead_purpose[lang]}
setPurchasePurpose(purchasePurpose === 'self_use' ? null : 'self_use')} />
setPurchasePurpose(purchasePurpose === 'investment' ? null : 'investment')} />
{t.pr_lead_payment[lang]}
setPaymentMethod(paymentMethod === 'loan' ? null : 'loan')} />
setPaymentMethod(paymentMethod === 'cash' ? null : 'cash')} />
setHasPets(v => !v)} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
{hasPets ? '✓' : ''}
{t.inquire_pet_friendly_label[lang]}
)}
{!!error &&
{error}
}
{submitting ? t.gen_inq_submitting[lang] : t.gen_inq_submit[lang]}
)}
);
}
function CustPrChip({ label, active, onClick }) {
return (
{label}
);
}
const PR_BUDGET_OPTIONS = ['< 2M', '2-3M', '3-5M', '5-10M', '10M+'];
const PR_UNIT_SIZE_OPTIONS = ['1 Bed', '2 Bed', '3 Bed', 'Penthouse'];
const PR_INCOME_OPTIONS = ['< 30K', '30-50K', '50-100K', '100-200K', '200K+'];
const PR_AGE_OPTIONS = ['20-29', '30-39', '40-49', '50-59', '60+'];
function CustProjectInquireModal({ lang, project, onClose }) {
const t = CUST_DICT;
const [budgetRange, setBudgetRange] = useState(null);
const [desiredUnitSize, setDesiredUnitSize] = useState(null);
const [paymentMethod, setPaymentMethod] = useState(null);
const [purchasePurpose, setPurchasePurpose] = useState(null);
const [decisionTimeline, setDecisionTimeline] = useState(null);
const [firstHome, setFirstHome] = useState(null);
const [occupation, setOccupation] = useState('');
const [jobPosition, setJobPosition] = useState('');
const [incomeRange, setIncomeRange] = useState(null);
const [ageRange, setAgeRange] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const loggedIn = SvlmApi.isCustomerLoggedIn();
async function submit() {
if (submitting) return;
setSubmitting(true);
setError('');
try {
await SvlmApi.submitProjectInquiry({
projectReviewId: project.id,
budgetRange: budgetRange || undefined,
desiredUnitSize: desiredUnitSize || undefined,
paymentMethod: paymentMethod || undefined,
purchasePurpose: purchasePurpose || undefined,
decisionTimeline: decisionTimeline || undefined,
firstHome: firstHome === null ? undefined : firstHome,
occupation: occupation.trim() || undefined,
jobPosition: jobPosition.trim() || undefined,
incomeRange: incomeRange || undefined,
ageRange: ageRange || undefined,
});
setDone(true);
} catch (err) {
setError(t.pr_lead_error[lang]);
} finally {
setSubmitting(false);
}
}
return (
{t.pr_lead_title[lang]}
✕
{done ? (
{t.pr_lead_success[lang]}
) : !loggedIn ? (
{t.pr_lead_login_required[lang]}
) : (
{t.pr_lead_budget[lang]}
{PR_BUDGET_OPTIONS.map(v => setBudgetRange(budgetRange === v ? null : v)} />)}
{t.pr_lead_unit_size[lang]}
{PR_UNIT_SIZE_OPTIONS.map(v => setDesiredUnitSize(desiredUnitSize === v ? null : v)} />)}
{t.pr_lead_payment[lang]}
setPaymentMethod(paymentMethod === 'loan' ? null : 'loan')} />
setPaymentMethod(paymentMethod === 'cash' ? null : 'cash')} />
{t.pr_lead_purpose[lang]}
setPurchasePurpose(purchasePurpose === 'self_use' ? null : 'self_use')} />
setPurchasePurpose(purchasePurpose === 'investment' ? null : 'investment')} />
{t.pr_lead_timeline[lang]}
setDecisionTimeline(decisionTimeline === 'immediate' ? null : 'immediate')} />
setDecisionTimeline(decisionTimeline === '1_3_months' ? null : '1_3_months')} />
setDecisionTimeline(decisionTimeline === '3_6_months' ? null : '3_6_months')} />
setDecisionTimeline(decisionTimeline === 'over_6_months' ? null : 'over_6_months')} />
{t.pr_lead_first_home[lang]}
setFirstHome(firstHome === true ? null : true)} />
setFirstHome(firstHome === false ? null : false)} />
{t.pr_lead_income[lang]}
{PR_INCOME_OPTIONS.map(v => setIncomeRange(incomeRange === v ? null : v)} />)}
{t.pr_lead_age[lang]}
{PR_AGE_OPTIONS.map(v => setAgeRange(ageRange === v ? null : v)} />)}
{!!error &&
{error}
}
{submitting ? t.pr_lead_submitting[lang] : t.pr_lead_submit[lang]}
)}
);
}
function CustCalcInput({ label, value, onChange, type, placeholder }) {
return (
);
}
function CustMortgageCalc({ lang, initialLoanAmount }) {
const t = CUST_DICT;
const [loanAmount, setLoanAmount] = useState(initialLoanAmount || '');
const [rate, setRate] = useState('6.5');
const [termYears, setTermYears] = useState('30');
const [method, setMethod] = useState('effective');
const [income, setIncome] = useState('');
const [debt, setDebt] = useState('');
const [dsr, setDsr] = useState('40');
const [result, setResult] = useState(null);
const [calculating, setCalculating] = useState(false);
const [error, setError] = useState('');
async function calculate() {
if (!loanAmount || calculating) return;
setCalculating(true);
setError('');
try {
const payload = {
loanAmount: Number(loanAmount),
annualInterestRatePercent: Number(rate),
loanTermYears: Number(termYears),
interestMethod: method,
};
if (income) {
payload.monthlyIncome = Number(income);
payload.existingMonthlyDebt = debt ? Number(debt) : 0;
payload.dsrPercent = Number(dsr);
}
const data = await SvlmApi.calculateMortgage(payload);
setResult(data);
} catch (err) {
setError(t.insurance_error[lang]);
} finally {
setCalculating(false);
}
}
const fmt = (v) => '฿' + Math.round(Number(v)).toLocaleString('en-US');
return (
{t.calc_mortgage_method[lang]}
{[['effective', t.calc_method_effective[lang]], ['flat', t.calc_method_flat[lang]]].map(([key, label]) => (
setMethod(key)} style={{ flex: 1, textAlign: 'center', padding: '9px 6px', borderRadius: 10, fontSize: 12, fontWeight: 700, cursor: 'pointer', background: method === key ? '#FF6A1A' : '#F5EEE8', color: method === key ? '#fff' : '#201812' }}>{label}
))}
{!!income &&
}
{!!income && (
{t.calc_dsr[lang]}
{['40', '50'].map(v => (
setDsr(v)} style={{ flex: 1, textAlign: 'center', padding: '9px 6px', borderRadius: 10, fontSize: 12, fontWeight: 700, cursor: 'pointer', background: dsr === v ? '#FF6A1A' : '#F5EEE8', color: dsr === v ? '#fff' : '#201812' }}>{v}%
))}
)}
{!!error &&
{error}
}
{calculating ? t.calc_calculating[lang] : t.calc_calculate[lang]}
{!!result && (
{t.calc_monthly_payment[lang]}
{fmt(result.breakdown.monthlyPayment)}
{t.calc_total_interest[lang]}
{fmt(result.breakdown.totalInterest)}
{t.calc_total_payment[lang]}
{fmt(result.breakdown.totalPayment)}
{t.calc_quick_estimate[lang]}
{fmt(result.quickEstimateRange.low)} - {fmt(result.quickEstimateRange.high)}
{!!result.eligibility && (
{result.eligibility.eligible ? t.calc_eligible_yes[lang] : t.calc_eligible_no[lang]}
{t.calc_max_loan[lang]}
{fmt(result.eligibility.maxLoanAmount)}
)}
)}
);
}
function CustTransferTaxCalc({ lang }) {
const t = CUST_DICT;
const [appraisedValue, setAppraisedValue] = useState('');
const [salePrice, setSalePrice] = useState('');
const [propertyType, setPropertyType] = useState('second_hand');
const [sellerType, setSellerType] = useState('individual');
const [yearsOwned, setYearsOwned] = useState('1');
const [registeredOverOneYear, setRegisteredOverOneYear] = useState(false);
const [loanAmount, setLoanAmount] = useState('');
const [result, setResult] = useState(null);
const [calculating, setCalculating] = useState(false);
const [error, setError] = useState('');
async function calculate() {
if (!appraisedValue || !salePrice || calculating) return;
setCalculating(true);
setError('');
try {
const data = await SvlmApi.calculateTransferTax({
appraisedValue: Number(appraisedValue),
salePrice: Number(salePrice),
propertyType,
sellerType,
yearsOwned: Number(yearsOwned),
registeredOverOneYear,
loanAmount: loanAmount ? Number(loanAmount) : undefined,
});
setResult(data);
} catch (err) {
setError(t.insurance_error[lang]);
} finally {
setCalculating(false);
}
}
const fmt = (v) => '฿' + Math.round(Number(v)).toLocaleString('en-US');
return (
{t.calc_transfer_appraised_link[lang]}
{t.calc_transfer_property_type[lang]}
{[['first_hand', t.calc_type_first_hand[lang]], ['second_hand', t.calc_type_second_hand[lang]]].map(([key, label]) => (
setPropertyType(key)} style={{ flex: 1, textAlign: 'center', padding: '9px 6px', borderRadius: 10, fontSize: 12, fontWeight: 700, cursor: 'pointer', background: propertyType === key ? '#FF6A1A' : '#F5EEE8', color: propertyType === key ? '#fff' : '#201812' }}>{label}
))}
{t.calc_transfer_seller_type[lang]}
{[['individual', t.calc_seller_individual[lang]], ['company', t.calc_seller_company[lang]]].map(([key, label]) => (
setSellerType(key)} style={{ flex: 1, textAlign: 'center', padding: '9px 6px', borderRadius: 10, fontSize: 12, fontWeight: 700, cursor: 'pointer', background: sellerType === key ? '#FF6A1A' : '#F5EEE8', color: sellerType === key ? '#fff' : '#201812' }}>{label}
))}
setRegisteredOverOneYear(v => !v)} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
{registeredOverOneYear ? '✓' : ''}
{t.calc_transfer_registered[lang]}
{!!error &&
{error}
}
{calculating ? t.calc_calculating[lang] : t.calc_calculate[lang]}
{!!result && (
{t.calc_fee_transfer[lang]}
{fmt(result.breakdown.transferFee.amount)}
{t.calc_fee_sbt[lang]}
{fmt(result.breakdown.specificBusinessTax.amount)}
{t.calc_fee_stamp[lang]}
{fmt(result.breakdown.stampDuty.amount)}
{t.calc_fee_wht[lang]}
{fmt(result.breakdown.withholdingTax.amount)}
{!!result.breakdown.mortgageFee.applies && (
{t.calc_fee_mortgage[lang]}
{fmt(result.breakdown.mortgageFee.amount)}
)}
{t.calc_fee_total[lang]}
{fmt(result.totalTransferCosts)}
)}
);
}
function CustCalculators({ lang, initialTab, initialLoanAmount, onClose }) {
const t = CUST_DICT;
const [tool, setTool] = useState(initialTab || 'mortgage');
return (
{[['mortgage', t.calc_tab_mortgage[lang]], ['transfer', t.calc_tab_transfer[lang]]].map(([key, label]) => (
setTool(key)} style={{ flex: 1, textAlign: 'center', padding: '10px 6px', borderRadius: 12, fontSize: 13, fontWeight: 700, cursor: 'pointer', background: tool === key ? '#FF6A1A' : '#F5EEE8', color: tool === key ? '#fff' : '#201812' }}>{label}
))}
{tool === 'mortgage' ? : }
);
}
function CustInsuranceModal({ lang, onClose }) {
const t = CUST_DICT;
const [coverageType, setCoverageType] = useState('both');
const [fullName, setFullName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [propertyValue, setPropertyValue] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const loggedIn = SvlmApi.isCustomerLoggedIn();
async function submit() {
if (submitting || !fullName.trim() || !phone.trim()) return;
setSubmitting(true);
setError('');
try {
await SvlmApi.submitInsuranceQuoteRequest({
coverageType,
fullName: fullName.trim(),
phone: phone.trim(),
email: email.trim() || undefined,
propertyValue: propertyValue ? Number(propertyValue) : undefined,
notes: notes.trim() || undefined,
});
setDone(true);
} catch (err) {
setError(t.insurance_error[lang]);
} finally {
setSubmitting(false);
}
}
return (
{t.insurance_title[lang]}
✕
{done ? (
{t.insurance_success[lang]}
) : !loggedIn ? (
{t.insurance_login_required[lang]}
) : (
{t.insurance_subtitle[lang]}
{t.insurance_coverage[lang]}
{[['building', t.insurance_coverage_building[lang]], ['contents', t.insurance_coverage_contents[lang]], ['both', t.insurance_coverage_both[lang]]].map(([key, label]) => (
setCoverageType(key)} style={{ flex: 1, textAlign: 'center', padding: '9px 6px', borderRadius: 10, fontSize: 11.5, fontWeight: 700, cursor: 'pointer', background: coverageType === key ? '#FF6A1A' : '#F5EEE8', color: coverageType === key ? '#fff' : '#201812' }}>{label}
))}
{!!error &&
{error}
}
{submitting ? t.insurance_submitting[lang] : t.insurance_submit[lang]}
)}
);
}
function CustPropertyDetail({ lang, property, onClose, onChat, onShare, onOpenCalculator, onOpenBooking, ownerMode, statusBadge }) {
const loggedIn = SvlmApi.isCustomerLoggedIn();
const [tab, setTab] = useState('overview');
const [photoIndex, setPhotoIndex] = useState(0);
const [availStatus, setAvailStatus] = useState(property?.availabilityStatus || 'ready');
const [availDate, setAvailDate] = useState(property?.availabilityDate || '');
const [savingAvail, setSavingAvail] = useState(false);
const [availSaved, setAvailSaved] = useState(false);
const [juristicOpen, setJuristicOpen] = useState(false);
const [juristicMessage, setJuristicMessage] = useState('');
const [juristicSending, setJuristicSending] = useState(false);
const [juristicResult, setJuristicResult] = useState(null);
async function sendJuristicRequest() {
setJuristicSending(true);
setJuristicResult(null);
try {
const res = await SvlmApi.requestJuristicService(property.id, juristicMessage.trim());
setJuristicResult(res && res.sent ? 'sent' : (res && res.error && res.error.startsWith('No juristic contact') ? 'no_contact' : 'error'));
if (res && res.sent) setJuristicMessage('');
} catch (e) {
setJuristicResult('error');
} finally {
setJuristicSending(false);
}
}
async function saveAvailability() {
setSavingAvail(true);
setAvailSaved(false);
try {
await SvlmApi.updateMyListingAvailability(property.id, availStatus, availStatus === 'available_from' ? availDate : undefined);
setAvailSaved(true);
} catch (e) {
window.alert(CUST_DICT.avail_error[lang]);
} finally {
setSavingAvail(false);
}
}
const photoScrollRef = React.useRef(null);
const goToPhoto = (i) => {
const el = photoScrollRef.current;
if (el) el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' });
};
if (!property) return null;
const t = CUST_DICT;
const canBook = (property.rentalTerm === 'short' || property.rentalTerm === 'both') && property.leaseTerms && property.leaseTerms.length > 0 && property.monthlyPrices;
const tabs = [
{ key: 'overview', label: t.detail_overview[lang] },
{ key: 'floorplan', label: t.detail_floorplan[lang] },
{ key: 'location', label: t.detail_location[lang] },
];
// A submission the owner hasn't finished (or that Ops hasn't approved) has
// no real amenity list yet — showing the generic placeholder list here
// would look like it was actually submitted, so ownerMode skips it.
const amenities = property.amenities && property.amenities.length ? property.amenities : (ownerMode ? [] : CUST_AMENITIES[lang]);
const photos = (property.photos && property.photos.length) ? property.photos : (property.coverUrl ? [property.coverUrl] : []);
const maxBuildableArea = (property.far && property.area) ? Math.round(property.far * property.area) : null;
return (
{photos.length ? (
setPhotoIndex(Math.round(e.target.scrollLeft / Math.max(1, e.target.clientWidth)))}
className="cust-noscroll"
style={{ display: 'flex', overflowX: 'auto', scrollSnapType: 'x mandatory', height: 280, WebkitOverflowScrolling: 'touch' }}
>
{photos.map((url, i) => (
))}
) :
}
{photos.length > 1 && photoIndex > 0 && (
goToPhoto(photoIndex - 1)} style={{ position: 'absolute', top: '50%', left: 12, transform: 'translateY(-50%)', width: 32, height: 32, borderRadius: 999, background: 'rgba(32,24,18,.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
)}
{photos.length > 1 && photoIndex < photos.length - 1 && (
goToPhoto(photoIndex + 1)} style={{ position: 'absolute', top: '50%', right: 12, transform: 'translateY(-50%)', width: 32, height: 32, borderRadius: 999, background: 'rgba(32,24,18,.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
)}
{photos.length > 1 && (
{photoIndex + 1}/{photos.length}
)}
{CUST_TAGS[property.tag] ? CUST_TAGS[property.tag].label[lang] : ''}
{!!custAvailabilityBadge(property, lang) &&
{custAvailabilityBadge(property, lang).label}
}
{property.name}
{!!statusBadge &&
{statusBadge.label}
}
{property.loc}
{property.price}
{ownerMode && (property.viewsCount != null || property.leadsCount != null) && (
{(property.viewsCount || 0).toLocaleString('en-US')}
{t.owner_stat_views[lang]}
{(property.leadsCount || 0).toLocaleString('en-US')}
{t.owner_stat_leads[lang]}
)}
{ownerMode && (
{t.avail_edit_title[lang]}
{t.avail_edit_sub[lang]}
{[
{ key: 'ready', label: t.avail_ready[lang] },
{ key: 'available_from', label: t.avail_available_from[lang] },
{ key: 'not_available', label: t.avail_not_available[lang] },
].map(o => (
setAvailStatus(o.key)} className="cust-chip" style={{ flex: 1, textAlign: 'center', padding: '9px 4px', fontSize: 12, background: availStatus === o.key ? '#FF6A1A' : '#fff', color: availStatus === o.key ? '#fff' : '#7A6F68' }}>{o.label}
))}
{availStatus === 'available_from' && (
setAvailDate(e.target.value)} style={{ width: '100%', height: 42, borderRadius: 10, border: '1.5px solid #F1E6DC', padding: '0 12px', fontSize: 13.5, outline: 'none', fontFamily: 'inherit', color: '#201812', marginBottom: 10, background: '#fff' }} />
)}
{availSaved ? t.avail_saved[lang] : t.avail_save[lang]}
)}
{ownerMode && (
{t.juristic_request_title[lang]}
{t.juristic_request_sub[lang]}
{!juristicOpen ? (
{ setJuristicOpen(true); setJuristicResult(null); }}>{t.juristic_request_button[lang]}
) : (
)}
)}
{!!(property.leaseTerms && property.leaseTerms.length && property.monthlyPrices) && (
{t.detail_monthly_rates_title[lang]}
{property.leaseTerms.map(m => (
{m} {t.detail_monthly_rates_mo[lang]}
฿{Number(property.monthlyPrices[m] || 0).toLocaleString('en-US')}
))}
)}
{property.beds} {t.beds_label[lang]}
{property.baths} {t.baths_label[lang]}
{property.area} {t.area_label[lang]}
{property.mortgage && (
{t.detail_installment[lang]}
{property.mortgage.installment}
{t.detail_min_income[lang]}
{property.mortgage.minIncome}
)}
{!!property.mortgage && (
onOpenCalculator(property.priceValue ? Math.round(property.priceValue * 0.9) : '')} style={{ fontSize: 12, fontWeight: 700, color: '#FF6A1A', cursor: 'pointer', marginTop: -10, marginBottom: 18 }}>
{t.detail_full_calculator[lang]}
)}
{tabs.map(tb => (
setTab(tb.key)} style={{ padding: '0 0 12px', fontSize: 13, fontWeight: 700, color: tab === tb.key ? '#FF6A1A' : '#8A7C74', borderBottom: tab === tb.key ? '2px solid #FF6A1A' : '2px solid transparent', cursor: 'pointer' }}>{tb.label}
))}
{tab === 'overview' && (
{!!property.desc &&
{property.desc}
}
{!!property.far && (
{t.detail_far[lang]}
{property.far}
{maxBuildableArea != null && (
{t.detail_max_buildable[lang]}
{maxBuildableArea.toLocaleString('en-US')} {lang === 'en' ? 'sqm' : 'ตร.ม.'}
)}
)}
{amenities.map((a, i) => (
{a}
))}
)}
{tab === 'floorplan' &&
}
{tab === 'location' && (
)}
{!ownerMode && (
)}
{!ownerMode && (
{t.detail_chat[lang]}
{canBook && {t.booking_cta[lang]} }
)}
);
}
function CustProjectReviewDetail({ lang, project, onClose }) {
const [photoIndex, setPhotoIndex] = useState(0);
const [showInquire, setShowInquire] = useState(false);
const photoScrollRef = React.useRef(null);
const goToPhoto = (i) => {
const el = photoScrollRef.current;
if (el) el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' });
};
if (!project) return null;
const t = CUST_DICT;
const photos = project.photos || [];
function sizeRangeLabel(min, max) {
if (min == null && max == null) return '—';
if (min != null && max != null && min !== max) return Number(min) + '-' + Number(max);
return Number(min != null ? min : max);
}
return (
{photos.length ? (
setPhotoIndex(Math.round(e.target.scrollLeft / Math.max(1, e.target.clientWidth)))}
className="cust-noscroll"
style={{ display: 'flex', overflowX: 'auto', scrollSnapType: 'x mandatory', height: 280, WebkitOverflowScrolling: 'touch' }}
>
{photos.map((url, i) => (
))}
) :
}
{photos.length > 1 && photoIndex > 0 && (
goToPhoto(photoIndex - 1)} style={{ position: 'absolute', top: '50%', left: 12, transform: 'translateY(-50%)', width: 32, height: 32, borderRadius: 999, background: 'rgba(32,24,18,.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
)}
{photos.length > 1 && photoIndex < photos.length - 1 && (
goToPhoto(photoIndex + 1)} style={{ position: 'absolute', top: '50%', right: 12, transform: 'translateY(-50%)', width: 32, height: 32, borderRadius: 999, background: 'rgba(32,24,18,.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
)}
{photos.length > 1 && (
{photoIndex + 1}/{photos.length}
)}
{project.name}
{project.loc}
{!!project.price &&
{project.price}
}
{project.completionYear != null && (
{project.completionYear}
{t.pr_completion_year[lang]}
)}
{project.floors != null && (
{project.floors}
{t.pr_floors[lang]}
)}
{project.totalUnits != null && (
{project.totalUnits}
{t.pr_total_units[lang]}
)}
{project.unitTypes.length > 0 && (
{t.pr_unit_types[lang]}
{project.unitTypes.map(u => (
{u.layoutName}
{!!u.bedroomType &&
{u.bedroomType}
}
{sizeRangeLabel(u.sizeSqmMin, u.sizeSqmMax)} {t.area_label[lang]}
{!!u.startingPricePerSqm &&
{t.pr_price_per_sqm[lang]} ฿{Math.round(Number(u.startingPricePerSqm)).toLocaleString('en-US')}/{t.area_label[lang]}
}
))}
)}
{(project.nearbyLandmarks.length > 0 || project.nearbyTransit.length > 0) && (
{t.pr_location[lang]}
{project.nearbyTransit.map((tr, i) => (
))}
{project.nearbyLandmarks.map((lm, i) => (
))}
)}
{project.facilities.length > 0 && (
{t.pr_facilities[lang]}
{project.facilities.map((f, i) => (
{f}
))}
)}
{project.promotions.length > 0 && (
{t.pr_promotions[lang]}
{project.promotions.map((p, i) => (
• {p}
))}
)}
setShowInquire(true)}>{t.pr_contact_sales[lang]}
{showInquire &&
setShowInquire(false)} />}
);
}
/* ---------------- List-property overlay ---------------- */
function Field({ label, children, grow }) {
return (
);
}
// Location picker (map + search) is the shared SvlmMapPicker from icons.jsx — used here and by the Agent/Ops Add Property forms.
function ReviewSection({ title, children }) {
return (
);
}
function ReviewRow({ label, value }) {
return (
);
}
function CustListProperty({ lang, submitted, submitting, submitError, onClose, onSubmit, selectedLocation, openProvincePicker, requireOwnerIdentity }) {
const t = CUST_DICT;
const [dealSale, setDealSale] = useState(true);
const [dealRent, setDealRent] = useState(false);
const [category, setCategory] = useState(CUST_LIST_CATEGORY_KEYS[0].en);
const [title, setTitle] = useState('');
const [unitNumber, setUnitNumber] = useState('');
const [buildingName, setBuildingName] = useState('');
const [address, setAddress] = useState('');
const [mapPos, setMapPos] = useState(null);
const [salePrice, setSalePrice] = useState('');
const [rentPrice, setRentPrice] = useState('');
const [shortTermEnabled, setShortTermEnabled] = useState(false);
const [leaseTerms, setLeaseTerms] = useState([]);
const [monthlyPrices, setMonthlyPrices] = useState({});
const [area, setArea] = useState('');
const [far, setFar] = useState('');
const [beds, setBeds] = useState('');
const [baths, setBaths] = useState('');
const [petPolicy, setPetPolicy] = useState('');
const [unitLayout, setUnitLayout] = useState('');
const [transit, setTransit] = useState([]);
const [amenityIds, setAmenityIds] = useState([]);
const [amenitiesCatalog, setAmenitiesCatalog] = useState([]);
const [description, setDescription] = useState('');
const [photos, setPhotos] = useState([]);
const [ownerName, setOwnerName] = useState('');
const [ownerPhone, setOwnerPhone] = useState('');
const [ownerLineId, setOwnerLineId] = useState('');
const [ownerEmail, setOwnerEmail] = useState('');
const [ownerWhatsapp, setOwnerWhatsapp] = useState('');
const [ownerFacebook, setOwnerFacebook] = useState('');
const [exclusive, setExclusive] = useState(false);
const [exclusiveMonths, setExclusiveMonths] = useState(3);
const [step, setStep] = useState('form'); // 'form' | 'review'
const fileInputRef = React.useRef(null);
function toggleLeaseTerm(m) {
setLeaseTerms(list => {
if (list.includes(m)) {
setMonthlyPrices(p => { const n = { ...p }; delete n[m]; return n; });
return list.filter(x => x !== m);
}
return [...list, m].sort((a, b) => a - b);
});
}
function setTermPrice(m, val) {
setMonthlyPrices(p => ({ ...p, [m]: val }));
}
function toggleTransit(name) {
setTransit(list => list.includes(name) ? list.filter(x => x !== name) : [...list, name]);
}
function toggleAmenity(id) {
setAmenityIds(list => list.includes(id) ? list.filter(x => x !== id) : [...list, id]);
}
useEffect(() => {
SvlmApi.fetchAmenities().then(setAmenitiesCatalog).catch(err => console.error('SVLM: amenities failed', err));
}, []);
function handleAddPhotos(files) {
const next = Array.from(files).map(file => ({ file, previewUrl: URL.createObjectURL(file) }));
setPhotos(p => [...p, ...next].slice(0, 8));
}
function removePhoto(i) {
setPhotos(p => p.filter((_, idx) => idx !== i));
}
// The first entry in `photos` becomes the listing's cover photo (backend
// marks photoUrls[0] isCover) — tapping a later photo promotes it to the
// front instead of needing a separate "set cover" control.
function makeCoverPhoto(i) {
setPhotos(p => { if (i === 0) return p; const n = p.slice(); const [chosen] = n.splice(i, 1); n.unshift(chosen); return n; });
}
function doSubmit() {
onSubmit({
title: title.trim(),
type: category,
dealSale, dealRent,
salePrice, rentPrice,
shortTermEnabled, leaseTerms, monthlyPrices,
unitNumber: unitNumber.trim(),
buildingName: buildingName.trim(),
address: address.trim(),
lat: mapPos ? mapPos.lat : undefined,
lng: mapPos ? mapPos.lng : undefined,
area, far, beds, baths,
petPolicy,
unitLayout,
nearbyTransit: transit,
amenityIds,
description: description.trim(),
photos: photos.map(p => p.file),
province: selectedLocation,
ownerName: ownerName.trim(),
ownerPhone: ownerPhone.trim(),
ownerLineId: ownerLineId.trim(),
ownerEmail: ownerEmail.trim(),
ownerWhatsapp: ownerWhatsapp.trim(),
ownerFacebook: ownerFacebook.trim(),
soleAgency: exclusive,
exclusivePeriodMonths: exclusive ? exclusiveMonths : undefined,
});
}
const shortTermValid = !dealRent || !shortTermEnabled || (leaseTerms.length > 0 && leaseTerms.every(m => Number(monthlyPrices[m]) > 0));
// A logged-in customer's account already identifies them, so owner
// name/phone/email stay optional on that path -- but the availability-
// confirm handoff (requireOwnerIdentity) has no login at all, so these are
// the only way to know who's actually submitting.
const ownerIdentityValid = !requireOwnerIdentity || (ownerName.trim() && ownerPhone.trim() && ownerEmail.trim());
const canSubmit = title.trim() && selectedLocation && (dealSale || dealRent)
&& (!dealSale || salePrice.trim()) && (!dealRent || rentPrice.trim()) && shortTermValid && ownerIdentityValid;
return (
setStep('form') : onClose} />
{step === 'review' && !submitted ? t.list_review_title[lang] : t.list_title[lang]}
{submitted ? (
{t.list_success_title[lang]}
{t.list_success_sub[lang]}
{t.list_success_done[lang]}
) : step === 'form' ? (
{ setDealSale(true); setDealRent(false); }} style={{ flex: 1, textAlign: 'center', padding: '11px 0', borderRadius: 12, fontSize: 13.5, fontWeight: 700, background: (dealSale && !dealRent) ? '#FF6A1A' : '#F5EEE8', color: (dealSale && !dealRent) ? '#fff' : '#7A6F68', cursor: 'pointer' }}>{t.list_type_sale[lang]}
{ setDealSale(false); setDealRent(true); }} style={{ flex: 1, textAlign: 'center', padding: '11px 0', borderRadius: 12, fontSize: 13.5, fontWeight: 700, background: (dealRent && !dealSale) ? '#FF6A1A' : '#F5EEE8', color: (dealRent && !dealSale) ? '#fff' : '#7A6F68', cursor: 'pointer' }}>{t.list_type_rent[lang]}
{ setDealSale(true); setDealRent(true); }} style={{ flex: 1, textAlign: 'center', padding: '11px 0', borderRadius: 12, fontSize: 13.5, fontWeight: 700, background: (dealSale && dealRent) ? '#FF6A1A' : '#F5EEE8', color: (dealSale && dealRent) ? '#fff' : '#7A6F68', cursor: 'pointer' }}>{t.list_type_both[lang]}
{CUST_LIST_CATEGORY_KEYS.map((c, i) => (
setCategory(c.en)} className="cust-chip" style={{ flexShrink: 0, padding: '9px 16px', background: category === c.en ? '#FFF1E6' : '#F5EEE8', color: category === c.en ? '#B3634A' : '#7A6F68' }}>{c[lang]}
))}
setTitle(e.target.value)} placeholder={lang === 'en' ? 'e.g. Condo near BTS Thonglor' : 'เช่น คอนโดใกล้ BTS ทองหล่อ'} />
setUnitNumber(e.target.value)} placeholder={t.list_unit_ph[lang]} />
setBuildingName(e.target.value)} placeholder={t.list_building_ph[lang]} />
{selectedLocation || t.list_province_ph[lang]}
setAddress(e.target.value)} placeholder={t.list_address_ph[lang]} />
{dealSale &&
setSalePrice(e.target.value)} placeholder="e.g. 6,500,000" />}
{dealRent &&
setRentPrice(e.target.value)} placeholder="e.g. 25,000" />}
{dealRent && (
setShortTermEnabled(v => !v)} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: 14, borderRadius: 14, background: shortTermEnabled ? '#FFF1E6' : '#F5EEE8', cursor: 'pointer' }}>
{shortTermEnabled && }
{t.list_shortterm_label[lang]}
{t.list_shortterm_sub[lang]}
)}
{dealRent && shortTermEnabled && (
{[1, 3, 6, 9].map(m => (
toggleLeaseTerm(m)} className="cust-chip" style={{ flex: 1, textAlign: 'center', padding: '9px 0', background: leaseTerms.includes(m) ? '#FF6A1A' : '#F5EEE8', color: leaseTerms.includes(m) ? '#fff' : '#7A6F68' }}>{m} {t.list_shortterm_term[lang]}
))}
{leaseTerms.map(m => (
))}
)}
setArea(e.target.value)} placeholder="sqm" />
setBeds(e.target.value)} placeholder="2" />
setBaths(e.target.value)} placeholder="1" />
setPetPolicy('allowed')} className="cust-chip" style={{ flex: 1, textAlign: 'center', padding: '11px 0', background: petPolicy === 'allowed' ? '#FF6A1A' : '#F5EEE8', color: petPolicy === 'allowed' ? '#fff' : '#7A6F68' }}>{t.filter_pet_yes[lang]}
setPetPolicy('not_allowed')} className="cust-chip" style={{ flex: 1, textAlign: 'center', padding: '11px 0', background: petPolicy === 'not_allowed' ? '#FF6A1A' : '#F5EEE8', color: petPolicy === 'not_allowed' ? '#fff' : '#7A6F68' }}>{t.filter_pet_no[lang]}
{[
{ key: 'simplex', label: t.list_unit_layout_simplex[lang] },
{ key: 'duplex', label: t.list_unit_layout_duplex[lang] },
{ key: 'penthouse', label: t.list_unit_layout_penthouse[lang] },
].map(o => (
setUnitLayout(o.key)} className="cust-chip" style={{ flex: 1, textAlign: 'center', padding: '11px 4px', background: unitLayout === o.key ? '#FF6A1A' : '#F5EEE8', color: unitLayout === o.key ? '#fff' : '#7A6F68' }}>{o.label}
))}
{CUST_LAND_CATEGORIES.includes(category) && (
setFar(e.target.value)} placeholder="e.g. 4 (from town plan zoning)" />
)}
{t.list_transit_bts[lang]}
{CUST_BTS_LINES.map((g, gi) => (
{g.stations.map((s, si) => (
toggleTransit(s)} className="cust-chip" style={{ flexShrink: 0, padding: '7px 12px', fontSize: 12, background: transit.includes(s) ? '#FFF1E6' : '#F5EEE8', color: transit.includes(s) ? '#B3634A' : '#7A6F68' }}>{s}
))}
))}
{t.list_transit_mrt[lang]}
{CUST_MRT_LINES.map((g, gi) => (
{g.stations.map((s, si) => (
toggleTransit(s)} className="cust-chip" style={{ flexShrink: 0, padding: '7px 12px', fontSize: 12, background: transit.includes(s) ? '#FFF1E6' : '#F5EEE8', color: transit.includes(s) ? '#B3634A' : '#7A6F68' }}>{s}
))}
))}
{['security', 'facility', 'furnishing', 'utility'].map(cat => {
const items = amenitiesCatalog.filter(a => a.category === cat);
if (!items.length) return null;
return (
{t['amenity_cat_' + cat][lang]}
{items.map(a => (
toggleAmenity(a.id)} className="cust-chip" style={{ flexShrink: 0, padding: '7px 12px', fontSize: 12, background: amenityIds.includes(a.id) ? '#FF6A1A' : '#F5EEE8', color: amenityIds.includes(a.id) ? '#fff' : '#7A6F68' }}>{lang === 'en' ? a.nameEn : a.nameTh}
))}
);
})}
{ if (e.target.files.length) handleAddPhotos(e.target.files); e.target.value = ''; }} />
{photos.length > 1 && {t.list_photos_cover_hint[lang]}
}
{photos.map((p, i) => (
makeCoverPhoto(i)} style={{ position: 'relative', width: 68, height: 68, cursor: i === 0 ? 'default' : 'pointer' }}>
{i === 0 &&
{t.list_photos_cover_badge[lang]}
}
{ e.stopPropagation(); removePhoto(i); }} style={{ position: 'absolute', top: -6, right: -6, width: 18, height: 18, borderRadius: 999, background: '#201812', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>✕
))}
{photos.length < 8 && (
fileInputRef.current && fileInputRef.current.click()} style={{ cursor: 'pointer' }}>
)}
{t.list_contact_title[lang]}
{requireOwnerIdentity && (
{t.list_contact_required_hint[lang]}
)}
setOwnerName(e.target.value)} />
setOwnerPhone(e.target.value)} placeholder="081-234-5678" />
setOwnerLineId(e.target.value)} />
setOwnerWhatsapp(e.target.value)} />
setOwnerEmail(e.target.value)} />
setOwnerFacebook(e.target.value)} />
setExclusive(v => !v)} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: 14, borderRadius: 14, background: exclusive ? '#FFF1E6' : '#F5EEE8', cursor: 'pointer' }}>
{exclusive && }
{t.list_exclusive_label[lang]}
{t.list_exclusive_sub[lang]}
{exclusive && (
{[3, 6, 9, 12].map(m => (
setExclusiveMonths(m)} className="cust-chip" style={{ flex: 1, textAlign: 'center', padding: '9px 0', background: exclusiveMonths === m ? '#FF6A1A' : '#F5EEE8', color: exclusiveMonths === m ? '#fff' : '#7A6F68' }}>{m} {lang === 'en' ? 'mo' : 'ด.'}
))}
)}
setStep('review')} disabled={!canSubmit}>{t.list_review_cta[lang]}
) : (
c.en === category) || {})[lang] || category} />
{(!!unitNumber.trim() || !!buildingName.trim()) && }
{!!address.trim() && }
{dealSale && }
{dealRent && }
{dealRent && shortTermEnabled && leaseTerms.map(m => (
))}
{CUST_LAND_CATEGORIES.includes(category) && }
{transit.length > 0 && (
{transit.map((s, i) =>
{s}
)}
)}
{amenityIds.length > 0 && (
{amenitiesCatalog.filter(a => amenityIds.includes(a.id)).map(a => (
{lang === 'en' ? a.nameEn : a.nameTh}
))}
)}
{photos.length > 0 && (
{photos.map((p, i) =>
)}
)}
{!!description.trim() && (
{description.trim()}
)}
{!!ownerLineId.trim() && }
{!!ownerWhatsapp.trim() && }
{!!ownerEmail.trim() && }
{!!ownerFacebook.trim() && }
{exclusive && (
)}
{!!submitError &&
{submitError}
}
setStep('form')} disabled={submitting}>{t.list_review_edit[lang]}
{submitting ? '…' : t.list_submit[lang]}
)}
);
}
/* ---------------- Province picker overlay ---------------- */
function CustProvincePicker({ lang, search, setSearch, onClose, onSelect }) {
const t = CUST_DICT;
const q = (search || '').trim().toLowerCase();
const list = CUST_PROVINCES_77.filter(p => !q || p.en.toLowerCase().includes(q) || p.th.includes(q));
return (
{!q && (
{t.province_popular[lang]}
{CUST_FEATURED_LOCATIONS.map((fl, i) => (
onSelect(fl[lang])} style={{ padding: '12px 14px', borderRadius: 12, background: '#FFF1E6', color: '#B3634A', fontSize: 13.5, fontWeight: 700, cursor: 'pointer' }}>{fl[lang]}
))}
)}
{t.province_all[lang]}
{list.map((p, i) => (
onSelect(p[lang])} style={{ padding: '13px 4px', borderBottom: '1px solid #F5EEE8', fontSize: 13.5, cursor: 'pointer' }}>{p[lang]}
))}
);
}
/* ---------------- Filter panel overlay ---------------- */
function CustFilterPanel({ lang, type, setType, priceMin, setPriceMin, priceMax, setPriceMax, areaMin, setAreaMin, areaMax, setAreaMax, floorMin, setFloorMin, floorMax, setFloorMax, district, setDistrict, districtOptions, transit, toggleTransit, beds, setBeds, pet, setPet, onClose, onClear, onSaveSearch }) {
const t = CUST_DICT;
const bedOptions = [1, 2, 3, 4];
const petOptions = [
{ key: 'any', label: t.filter_pet_any[lang] },
{ key: 'yes', label: t.filter_pet_yes[lang] },
{ key: 'no', label: t.filter_pet_no[lang] },
];
const inputStyle = { height: 46, borderRadius: 14, border: '1.5px solid #F1E6DC', padding: '0 16px', fontSize: 14, outline: 'none', fontFamily: 'inherit', color: '#201812' };
return (
{t.filter_type[lang]}
setType('')} className="cust-chip" style={{ padding: '9px 14px', background: type === '' ? '#FF6A1A' : '#F5EEE8', color: type === '' ? '#fff' : '#7A6F68' }}>{t.filter_type_all[lang]}
{CUST_TYPE_FILTER_OPTIONS.map((o, i) => (
setType(o.value)} className="cust-chip" style={{ padding: '9px 14px', background: type === o.value ? '#FF6A1A' : '#F5EEE8', color: type === o.value ? '#fff' : '#7A6F68' }}>{o[lang]}
))}
{t.list_transit_label[lang]}
{t.list_transit_bts[lang]}
{CUST_BTS_LINES.map((g, gi) => (
{g.stations.map((s, si) => (
toggleTransit(s)} className="cust-chip" style={{ flexShrink: 0, padding: '7px 12px', fontSize: 12, background: transit.includes(s) ? '#FF6A1A' : '#F5EEE8', color: transit.includes(s) ? '#fff' : '#7A6F68' }}>{s}
))}
))}
{t.list_transit_mrt[lang]}
{CUST_MRT_LINES.map((g, gi) => (
{g.stations.map((s, si) => (
toggleTransit(s)} className="cust-chip" style={{ flexShrink: 0, padding: '7px 12px', fontSize: 12, background: transit.includes(s) ? '#FF6A1A' : '#F5EEE8', color: transit.includes(s) ? '#fff' : '#7A6F68' }}>{s}
))}
))}
{t.filter_beds[lang]}
setBeds(0)} className="cust-chip" style={{ padding: '9px 14px', background: beds === 0 ? '#FF6A1A' : '#F5EEE8', color: beds === 0 ? '#fff' : '#7A6F68' }}>{t.filter_beds_any[lang]}
{bedOptions.map(n => (
setBeds(n)} className="cust-chip" style={{ padding: '9px 14px', background: beds === n ? '#FF6A1A' : '#F5EEE8', color: beds === n ? '#fff' : '#7A6F68' }}>{n}+
))}
{t.filter_pet_label[lang]}
{petOptions.map(o => (
setPet(o.key)} className="cust-chip" style={{ padding: '9px 14px', background: pet === o.key ? '#FF6A1A' : '#F5EEE8', color: pet === o.key ? '#fff' : '#7A6F68' }}>{o.label}
))}
{t.filter_save_search[lang]}
{t.filter_clear[lang]}
{t.filter_apply[lang]}
);
}
/* ---------------- Chat list ---------------- */
function formatChatTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
function CustChatList({ lang, chats, loading, query, onQueryChange, openThread }) {
const t = CUST_DICT;
return (
{t.chat_title[lang]}
onQueryChange(e.target.value)}
placeholder={t.chat_search_ph[lang]}
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, color: '#201812', fontFamily: 'inherit' }}
/>
{loading ? (
…
) : chats.length === 0 ? (
{t.chat_empty[lang]}
{t.chat_empty_sub[lang]}
) : (
{chats.map(c => (
openThread(c.id)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', cursor: 'pointer' }}>
{c.agentName}
{c.lastMessage || t.chat_no_messages[lang]}
{formatChatTime(c.lastMessageAt)}
{c.unread > 0 &&
{c.unread}
}
))}
)}
);
}
/* ---------------- Chat thread ---------------- */
function CustChatThread({ lang, conversation, messages, loading, input, onInputChange, onSend, sending, onBack }) {
const t = CUST_DICT;
const quick = [t.quick_book[lang], t.quick_price[lang], t.quick_location[lang]];
const scrollRef = React.useRef(null);
React.useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, loading]);
const agentName = (conversation && conversation.agentName) || '…';
return (
{agentName}
{t.msg_role_agent[lang]}
{loading ? (
…
) : messages.length === 0 ? (
{t.chat_no_messages[lang]}
) : messages.map((m, i) => (
))}
{quick.map((q, i) => (
onSend(q)} className="cust-chip" style={{ flexShrink: 0, padding: '8px 14px', background: '#FFF1E6', color: '#B3634A', border: '1px solid #F1E6DC', cursor: 'pointer' }}>{q}
))}
onInputChange(e.target.value)}
onKeyDown={e => e.key === 'Enter' && onSend()}
placeholder={t.msg_ph[lang]}
style={{ flex: 1, height: 46, borderRadius: 999, background: '#F5EEE8', border: 'none', padding: '0 16px', fontSize: 13, color: '#201812', outline: 'none', fontFamily: 'inherit' }}
/>
onSend()} style={{ width: 46, height: 46, borderRadius: 999, background: '#FF6A1A', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, cursor: 'pointer', opacity: sending || !input.trim() ? .6 : 1 }}>
);
}
/* ---------------- SVLM Juristic (tenant portal) ---------------- */
function CustTenantPortal({ lang, onClose }) {
const t = CUST_DICT;
const [loggedIn, setLoggedIn] = useState(SvlmApi.isTenantLoggedIn());
const [step, setStep] = useState('phone'); // 'phone' | 'code'
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [devHint, setDevHint] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [leases, setLeases] = useState([]);
const [leasesLoading, setLeasesLoading] = useState(false);
const [activeLeaseId, setActiveLeaseId] = useState(null);
const [tab, setTab] = useState('status'); // 'status' | 'documents' | 'chat' | 'service'
const [tenantDocs, setTenantDocs] = useState([]);
const [tenantDocsLoading, setTenantDocsLoading] = useState(false);
const [signingDocId, setSigningDocId] = useState(null);
const [signerName, setSignerName] = useState('');
const [signSubmitting, setSignSubmitting] = useState(false);
const [signError, setSignError] = useState(false);
const [messages, setMessages] = useState([]);
const [messagesLoading, setMessagesLoading] = useState(false);
const [chatInput, setChatInput] = useState('');
const [chatSending, setChatSending] = useState(false);
const chatScrollRef = React.useRef(null);
const [serviceType, setServiceType] = useState('cleaning');
const [serviceNote, setServiceNote] = useState('');
const [servicePreferredDate, setServicePreferredDate] = useState('');
const [servicePreferredSlot, setServicePreferredSlot] = useState('');
const [serviceSubmitting, setServiceSubmitting] = useState(false);
const [serviceSent, setServiceSent] = useState(false);
const [serviceHistory, setServiceHistory] = useState([]);
async function loadLeases() {
setLeasesLoading(true);
try {
const res = await SvlmApi.fetchTenantLeases();
const items = res.items || [];
setLeases(items);
if (items.length === 1) setActiveLeaseId(items[0].id);
} catch (err) {
console.error('SVLM: fetch tenant leases failed', err);
} finally {
setLeasesLoading(false);
}
}
useEffect(() => { if (loggedIn) loadLeases(); }, [loggedIn]);
async function submitPhone() {
if (!phone.trim() || busy) return;
setError('');
setBusy(true);
try {
const res = await SvlmApi.requestTenantOtp(phone.trim());
setDevHint(res && res.devCode ? ('Dev code: ' + res.devCode) : '');
setStep('code');
} catch (err) {
setError(t.juristic_otp_error[lang]);
} finally {
setBusy(false);
}
}
async function submitCode() {
if (!code.trim() || busy) return;
setError('');
setBusy(true);
try {
await SvlmApi.tenantVerifyOtp(phone.trim(), code.trim());
setLoggedIn(true);
} catch (err) {
setError(t.juristic_otp_error[lang]);
} finally {
setBusy(false);
}
}
async function loadChat(leaseId) {
setMessagesLoading(true);
try {
const res = await SvlmApi.fetchTenantChatMessages(leaseId);
setMessages(res.items || []);
} catch (err) {
console.error('SVLM: fetch tenant chat failed', err);
} finally {
setMessagesLoading(false);
}
}
useEffect(() => {
if (activeLeaseId && tab === 'chat') loadChat(activeLeaseId);
}, [activeLeaseId, tab]);
useEffect(() => {
if (chatScrollRef.current) chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
}, [messages]);
async function sendChat() {
if (!chatInput.trim() || chatSending || !activeLeaseId) return;
setChatSending(true);
try {
await SvlmApi.sendTenantChatMessage(activeLeaseId, chatInput.trim());
setChatInput('');
await loadChat(activeLeaseId);
} catch (err) {
console.error('SVLM: send tenant chat failed', err);
} finally {
setChatSending(false);
}
}
async function loadServiceHistory(leaseId) {
try {
const res = await SvlmApi.fetchTenantServiceRequests(leaseId);
setServiceHistory(res.items || []);
} catch (err) {
console.error('SVLM: fetch tenant service requests failed', err);
}
}
useEffect(() => {
if (activeLeaseId && tab === 'service') loadServiceHistory(activeLeaseId);
}, [activeLeaseId, tab]);
async function loadTenantDocs(leaseId) {
setTenantDocsLoading(true);
try {
const res = await SvlmApi.fetchTenantDocuments(leaseId);
setTenantDocs(res.items || []);
} catch (err) {
console.error('SVLM: fetch tenant documents failed', err);
} finally {
setTenantDocsLoading(false);
}
}
useEffect(() => {
if (activeLeaseId && tab === 'documents') loadTenantDocs(activeLeaseId);
}, [activeLeaseId, tab]);
function openSignForm(docId) {
setSigningDocId(docId);
setSignerName('');
setSignError(false);
}
async function submitSignature() {
if (!signerName.trim() || signSubmitting || !activeLeaseId || !signingDocId) return;
setSignSubmitting(true);
setSignError(false);
try {
await SvlmApi.signTenantDocument(activeLeaseId, signingDocId, signerName.trim());
setSigningDocId(null);
loadTenantDocs(activeLeaseId);
} catch (err) {
setSignError(true);
} finally {
setSignSubmitting(false);
}
}
async function submitService() {
if (!activeLeaseId || serviceSubmitting) return;
setServiceSubmitting(true);
try {
await SvlmApi.requestTenantService(activeLeaseId, serviceType, serviceNote.trim(), servicePreferredDate || undefined, servicePreferredSlot || undefined);
setServiceNote('');
setServicePreferredDate('');
setServicePreferredSlot('');
setServiceSent(true);
loadServiceHistory(activeLeaseId);
setTimeout(() => setServiceSent(false), 3000);
} catch (err) {
console.error('SVLM: request tenant service failed', err);
} finally {
setServiceSubmitting(false);
}
}
const activeLease = leases.find(l => l.id === activeLeaseId);
const SERVICE_TYPES = ['cleaning', 'handyman', 'appliance', 'ac', 'lighting', 'keycard', 'housekeeping'];
const SERVICE_TIME_SLOTS = ['morning', 'afternoon', 'evening'];
const todayIso = new Date().toISOString().slice(0, 10);
const fieldStyle = { height: 46, borderRadius: 12, border: '1.5px solid #F1E6DC', padding: '0 14px', fontSize: 14, outline: 'none', fontFamily: 'inherit', width: '100%', boxSizing: 'border-box', color: '#201812' };
return (
1 ? () => setActiveLeaseId(null) : onClose} />
{t.menu_juristic[lang]}
{!loggedIn ? (
{t.juristic_login_title[lang]}
{t.juristic_login_sub[lang]}
{step === 'phone' ? (
setPhone(e.target.value)} placeholder={t.juristic_phone_ph[lang]} style={{ ...fieldStyle, marginBottom: 14 }} />
{t.juristic_send_otp[lang]}
) : (
setCode(e.target.value)} placeholder={t.juristic_otp_ph[lang]} style={{ ...fieldStyle, marginBottom: 8 }} />
{!!devHint && {devHint}
}
{t.juristic_verify[lang]}
)}
{!!error &&
{error}
}
) : leasesLoading ? (
…
) : leases.length === 0 ? (
{t.juristic_no_lease[lang]}
) : !activeLeaseId ? (
{leases.map(l => (
setActiveLeaseId(l.id)} style={{ padding: '14px 16px', borderRadius: 14, border: '1px solid #F1E6DC', marginBottom: 10, cursor: 'pointer' }}>
{l.projectName || l.unitCode}
{l.unitCode}
))}
) : (
{['status', 'documents', 'chat', 'service'].map(tb => (
setTab(tb)} style={{ flex: 1, textAlign: 'center', padding: '12px 0', fontSize: 13, fontWeight: 700, color: tab === tb ? '#FF6A1A' : '#8A7C74', borderBottom: tab === tb ? '2px solid #FF6A1A' : '2px solid transparent', cursor: 'pointer' }}>
{t['juristic_tab_' + tb][lang]}
))}
{tab === 'status' && activeLease && (
{t.juristic_unit_label[lang]}
{activeLease.projectName || activeLease.unitCode}
{[activeLease.buildingName, activeLease.unitFloor ? ('Fl. ' + activeLease.unitFloor) : null, activeLease.unitCode].filter(Boolean).join(' · ')}
{t.juristic_status_renting[lang]}
{t.juristic_contract_period[lang]}
{activeLease.contractStart || '—'} – {activeLease.contractEnd || '—'}
{t.juristic_rent_amount[lang]}
฿{Number(activeLease.rentAmount || 0).toLocaleString()}
{t.juristic_rent_due[lang]}
{activeLease.rentDueDate || '—'}
{!!activeLease.rentDueDate && (
{activeLease.rentPaidAt ? t.juristic_paid[lang] : t.juristic_unpaid[lang]}
)}
{t.juristic_documents_title[lang]}
{[
{ label: t.juristic_doc_booking[lang], url: activeLease.bookingFormPdfUrl || activeLease.bookingFormUrl, color: '#3A7BD5' },
{ label: t.juristic_doc_contract[lang], url: activeLease.contractPdfUrl || activeLease.leaseContractUrl, color: '#B3634A' },
{ label: t.juristic_doc_furniture[lang], url: activeLease.furnitureListPdfUrl || (activeLease.furnitureListUrls && activeLease.furnitureListUrls[0]), color: '#2FAE66' },
].map((doc, i) => (
window.open(SvlmApi.resolveFileUrl(doc.url), '_blank') : undefined}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, border: '1px solid #F1E6DC', cursor: doc.url ? 'pointer' : 'default', opacity: doc.url ? 1 : 0.5 }}
>
{doc.url ? t.juristic_doc_download[lang] : t.juristic_doc_unavailable[lang]}
))}
{(activeLease.addonInternet || activeLease.addonInsurance || activeLease.addonCleaning || activeLease.addonLaundry) && (
{t.juristic_addons_title[lang]}
{activeLease.addonInternet && Internet }
{activeLease.addonInsurance && Insurance }
{activeLease.addonCleaning && Cleaning }
{activeLease.addonLaundry && Laundry }
)}
)}
{tab === 'documents' && (
{tenantDocsLoading ? (
…
) : tenantDocs.length === 0 ? (
{t.doclist_empty[lang]}
) : (
{tenantDocs.map(d => {
const label = (CUST_DOC_TYPE_LABEL[d.docType] || { en: d.docType, th: d.docType })[lang];
const total = Number(d.amount || 0) + Number(d.vatAmount || 0);
const signed = d.status === 'signed';
return (
{signed ? t.doclist_status_signed[lang] : t.doclist_status_sent[lang]}
{total > 0 &&
฿{total.toLocaleString('en-US')}
}
{!!d.fileUrl && (
window.open(SvlmApi.resolveFileUrl(d.fileUrl), '_blank')} style={{ fontSize: 12, fontWeight: 700, color: '#FF6A1A', cursor: 'pointer', marginBottom: 8 }}>{t.doclist_view_file[lang]}
)}
{signed ? (
{t.doclist_signed_by[lang]}: {d.signedByName}
) : signingDocId === d.id ? (
setSignerName(e.target.value)}
placeholder={t.doclist_sign_name_ph[lang]}
style={{ ...fieldStyle, height: 42, marginBottom: 8 }}
/>
{t.doclist_sign_consent[lang]}
{signSubmitting ? t.doclist_sign_submitting[lang] : t.doclist_sign_submit[lang]}
{signError &&
{t.doclist_sign_error[lang]}
}
) : (
openSignForm(d.id)}>{t.doclist_sign_button[lang]}
)}
);
})}
)}
)}
{tab === 'chat' && (
{messagesLoading ? (
…
) : messages.length === 0 ? (
{t.chat_no_messages ? t.chat_no_messages[lang] : ''}
) : messages.map(m => (
))}
)}
{tab === 'service' && (
{t.juristic_service_type_label[lang]}
{SERVICE_TYPES.map(s => (
setServiceType(s)} className="cust-chip" style={{ padding: '8px 14px', fontSize: 12, background: serviceType === s ? '#FF6A1A' : '#F5EEE8', color: serviceType === s ? '#fff' : '#7A6F68', cursor: 'pointer' }}>
{t['svc_type_' + s][lang]}
))}
{t.juristic_service_time_label[lang]}
setServicePreferredDate(e.target.value)} style={{ width: '100%', height: 46, borderRadius: 12, border: '1.5px solid #F1E6DC', padding: '0 14px', fontSize: 13.5, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', color: '#201812', marginBottom: 10 }} />
{SERVICE_TIME_SLOTS.map(s => (
setServicePreferredSlot(v => v === s ? '' : s)} className="cust-chip" style={{ padding: '8px 14px', fontSize: 12, background: servicePreferredSlot === s ? '#FF6A1A' : '#F5EEE8', color: servicePreferredSlot === s ? '#fff' : '#7A6F68', cursor: 'pointer' }}>
{t['juristic_time_slot_' + s][lang]}
))}
)}
)}
);
}
/* ---------------- AI assistant (floating chat) ---------------- */
function CustAiChat({ lang }) {
const t = CUST_DICT;
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const scrollRef = React.useRef(null);
React.useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, sending, open]);
async function send() {
const text = input.trim();
if (!text || sending) return;
setInput('');
const history = messages.map(m => ({ role: m.role, text: m.text }));
setMessages(list => [...list, { role: 'user', text }]);
setSending(true);
try {
const res = await SvlmApi.sendAiChat(text, history);
setMessages(list => [...list, { role: 'model', text: res.reply }]);
} catch (err) {
console.error('SVLM: AI chat failed', err);
setMessages(list => [...list, { role: 'model', text: t.ai_chat_error[lang], error: true }]);
} finally {
setSending(false);
}
}
return (
{!open && (
setOpen(true)} style={{ position: 'absolute', bottom: 84, right: 16, height: 46, borderRadius: 999, background: '#FF6A1A', display: 'flex', alignItems: 'center', gap: 8, padding: '0 16px 0 12px', cursor: 'pointer', boxShadow: '0 6px 18px rgba(255,106,26,.4)', zIndex: 48 }}>
{t.ai_chat_label[lang]}
)}
{open && (
{t.ai_chat_title[lang]}
{t.ai_chat_label[lang]}
setOpen(false)} style={{ width: 26, height: 26, borderRadius: 999, background: '#F5EEE8', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}>
{messages.length === 0 && (
{t.ai_chat_greeting[lang]}
)}
{messages.map((m, i) => (
))}
{sending &&
{t.ai_chat_thinking[lang]}
}
)}
);
}
/* ---------------- Profile ---------------- */
function CustProfile({ lang, profile, onClose, onEdit, onContactUs, onOpenPaymentMethods, onOpenSocialAccounts, onReferFriends, onOpenComingSoon, pushEnabled, onTogglePush, promoEnabled, onTogglePromo }) {
const t = CUST_DICT;
const name = profile ? profile.name : '…';
const generalItems = [
{ icon: 'wallet', label: t.profile_payment_methods[lang], onClick: onOpenPaymentMethods },
{ icon: 'pin', label: t.profile_locations[lang], onClick: onOpenComingSoon },
{ icon: 'share', label: t.profile_add_social[lang], onClick: onOpenSocialAccounts },
{ icon: 'users', label: t.profile_refer_friends[lang], onClick: onReferFriends },
];
return (
{name}
{(profile && (profile.email || profile.phone)) || ''}
{t.profile_general[lang]}
{generalItems.map((it, i) => (
))}
{t.profile_notifications[lang]}
{t.profile_push_notifications[lang]}
{t.profile_promotional_notifications[lang]}
{t.profile_more[lang]}
{t.profile_contact_us[lang]}
);
}
function CustSwitch({ checked, onChange }) {
return (
onChange(!checked)} style={{ width: 42, height: 24, borderRadius: 999, background: checked ? '#FF6A1A' : '#E4DAD1', padding: 3, cursor: 'pointer', flexShrink: 0, transition: 'background .15s' }}>
);
}
/* ---------------- SVLM's official social channels ---------------- */
const CUST_SOCIAL_CHANNELS = [
{ key: 'facebook', label: 'Facebook', mono: 'f', bg: '#1877F2', url: 'https://www.facebook.com/SVLPropertyManagement' },
{ key: 'instagram', label: 'Instagram', mono: 'IG', bg: 'linear-gradient(135deg,#F58529,#DD2A7B,#8134AF,#515BD4)', url: 'https://www.instagram.com/svlmthailand?stkn=MWdubGk4bjJid2Nhaw%3D%3D&utm_source=qr' },
{ key: 'youtube', label: 'YouTube', mono: '▶', bg: '#FF0000', url: 'https://www.youtube.com/@SVLMRealEstates' },
{ key: 'line', label: 'LINE', mono: 'L', bg: '#06C755', url: 'https://lin.ee/vfUT4qv' },
];
function CustSocialAccounts({ lang, onClose }) {
const t = CUST_DICT;
return (
{t.social_sub[lang]}
{CUST_SOCIAL_CHANNELS.map((c, i) => (
window.open(c.url, '_blank')} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 0', borderBottom: i < CUST_SOCIAL_CHANNELS.length - 1 ? '1px solid #F5EEE8' : 'none', cursor: 'pointer' }}>
))}
);
}
/* ---------------- Contact Us — direct channels to reach SVLM staff. ---------------- */
const CUST_CONTACT_CHANNELS = [
{ key: 'call', label: { en: 'Call Center', th: 'ศูนย์บริการลูกค้า' }, sub: '02-430-6578', mono: '☎', bg: '#FF6A1A', url: 'tel:024306578' },
{ key: 'line', label: { en: 'LINE', th: 'ไลน์' }, sub: '@svlm', mono: 'L', bg: '#06C755', url: 'https://lin.ee/vfUT4qv' },
{ key: 'messenger', label: { en: 'Facebook Messenger', th: 'เฟซบุ๊ก เมสเซนเจอร์' }, sub: 'm.me/100459143067354', mono: '✉', bg: 'linear-gradient(135deg,#00B2FF,#006AFF)', url: 'https://m.me/100459143067354' },
];
function CustContactUs({ lang, onClose }) {
const t = CUST_DICT;
return (
{t.contact_us_title[lang]}
{t.contact_us_sub[lang]}
{CUST_CONTACT_CHANNELS.map((c, i) => (
window.open(c.url, c.key === 'call' ? '_self' : '_blank')} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 0', borderBottom: i < CUST_CONTACT_CHANNELS.length - 1 ? '1px solid #F5EEE8' : 'none', cursor: 'pointer' }}>
))}
);
}
/* ---------------- Edit Profile ---------------- */
function CustEditProfile({ lang, profile, saving, error, onClose, onSave, onOpenChangePassword }) {
const t = CUST_DICT;
const [name, setName] = useState((profile && profile.name) || '');
const [email, setEmail] = useState((profile && profile.email) || '');
const [address, setAddress] = useState((profile && profile.address) || '');
const [zipCode, setZipCode] = useState((profile && profile.zipCode) || '');
const [state, setState] = useState((profile && profile.state) || '');
const canSave = name.trim() && !saving;
return (
{t.edit_profile_title[lang]}
);
}
/* ---------------- Change Password ---------------- */
function CustChangePassword({ lang, onClose }) {
const t = CUST_DICT;
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const canSave = currentPassword && newPassword && confirmPassword && !saving;
async function submit() {
setError('');
setSuccess(false);
if (newPassword.length < 8) { setError(t.change_password_too_short[lang]); return; }
if (newPassword !== confirmPassword) { setError(t.change_password_mismatch[lang]); return; }
setSaving(true);
try {
await SvlmApi.changeMyPasswordAsCustomer(currentPassword, newPassword);
setSuccess(true);
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('');
} catch (err) {
setError((err && err.message) || t.change_password_error[lang]);
} finally {
setSaving(false);
}
}
return (
{t.change_password_title[lang]}
);
}
const CARD_BRAND_COLORS = {
VISA: 'linear-gradient(135deg,#1A1F71,#4B5CC4)',
MASTERCARD: 'linear-gradient(135deg,#2b2b2b,#5c4a3a)',
JCB: 'linear-gradient(135deg,#0B4EA2,#1F8A3B)',
AMEX: 'linear-gradient(135deg,#016FD0,#0090DB)',
};
function cardBrandGradient(brand) {
return CARD_BRAND_COLORS[String(brand || '').toUpperCase()] || 'linear-gradient(135deg,#3a3a3a,#6a5a4a)';
}
/* ---------------- My Cards — mPay hosts the actual card entry + 3DS challenge
(redirect-based), so a raw card number never passes through this app or our
own server; only a masked last-4/brand/expiry ever comes back. ---------------- */
function CustPaymentMethods({ lang, cards, loading, onClose, onAddCard, onRemove }) {
const t = CUST_DICT;
const [confirming, setConfirming] = useState(false);
const [removingId, setRemovingId] = useState(null);
if (confirming) {
return (
setConfirming(false)} />
{t.cards_title[lang]}
{t.cards_redirect_title[lang]}
{t.cards_redirect_sub[lang]}
{t.cards_redirect_continue[lang]}
setConfirming(false)}>{t.cards_redirect_cancel[lang]}
);
}
return (
setConfirming(true)} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12.5, fontWeight: 700, color: '#FF6A1A', cursor: 'pointer' }}>
{t.cards_add_new[lang]}
{loading ? (
…
) : cards.length === 0 ? (
{t.cards_empty_title[lang]}
{t.cards_empty_sub[lang]}
{t.cards_back[lang]}
) : (
{cards.map(c => (
{c.cardBrand}
•••• •••• •••• {c.cardLast4}
{t.cards_expires[lang]} {String(c.cardExpMonth).padStart(2, '0')}/{String(c.cardExpYear).slice(-2)}
setRemovingId(removingId === c.id ? null : c.id)}
style={{ fontSize: 11, fontWeight: 700, cursor: 'pointer', background: 'rgba(255,255,255,.18)', padding: '5px 10px', borderRadius: 999 }}
>
{t.cards_remove[lang]}
{removingId === c.id && (
{t.cards_remove_confirm[lang]}
{ onRemove(c.id); setRemovingId(null); }} style={{ fontSize: 11, fontWeight: 800, cursor: 'pointer', textDecoration: 'underline' }}>{t.cards_remove[lang]}
)}
))}
)}
);
}
/* ---------------- Add-card result — shown after mPay redirects back into the
app. Confirms the real status with our backend (which enquires mPay directly
and releases the placeholder hold) rather than trusting the URL alone. ---------------- */
function CustAddCardResult({ lang, status, onClose }) {
const t = CUST_DICT;
const ok = status === 'success';
return (
{status === null ? (
…
) : (
{ok ? t.cards_added_title[lang] : t.cards_failed_title[lang]}
{ok ? t.cards_added_sub[lang] : t.cards_failed_sub[lang]}
{t.cards_added_back[lang]}
)}
);
}
/** Shown after mPay redirects back for a booking-deposit card payment (QR never leaves the app, so this is card-only). */
function CustBookingResult({ lang, status, onClose }) {
const t = CUST_DICT;
const ok = status === 'success';
return (
{status === null ? (
…
) : (
{ok ? t.booking_confirmed_title[lang] : t.booking_failed_title[lang]}
{ok ? t.booking_confirmed_sub[lang] : t.booking_failed_sub[lang]}
{t.booking_back[lang]}
)}
);
}
function bookingFormatThb(n) {
return '฿' + Math.round(Number(n) || 0).toLocaleString('en-US');
}
/* ---------------- Short-term booking checkout — pay a 20% deposit via mPay
to reserve a short-term-rental unit. QR stays in-app and polls for the
result; card redirects to mPay's hosted form the same way Add Card does. ---------------- */
function CustBookingCheckout({ lang, property, onClose }) {
const t = CUST_DICT;
const leaseTerms = property.leaseTerms || [];
const monthlyPrices = property.monthlyPrices || {};
const [selectedTerm, setSelectedTerm] = useState(leaseTerms[0] || null);
const [moveInDate, setMoveInDate] = useState('');
const [method, setMethod] = useState('qr');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [qrData, setQrData] = useState(null); // { bookingId, amount, currency, qrImage, qrCode }
const [pollStatus, setPollStatus] = useState('pending_payment');
const monthlyRate = selectedTerm != null ? Number(monthlyPrices[String(selectedTerm)]) || 0 : 0;
const deposit = Math.round(monthlyRate * 0.2);
const todayIso = new Date().toISOString().slice(0, 10);
useEffect(() => {
if (!qrData || pollStatus !== 'pending_payment') return;
const iv = setInterval(() => {
SvlmApi.fetchShortTermBookingStatus(qrData.bookingId)
.then(res => { if (res.status !== 'pending_payment') setPollStatus(res.status); })
.catch(() => {});
}, 3000);
return () => clearInterval(iv);
}, [qrData, pollStatus]);
async function submit() {
if (!selectedTerm) return;
setSubmitting(true);
setError('');
try {
const res = await SvlmApi.createShortTermBooking(property.id, selectedTerm, method, moveInDate || undefined);
if (res.method === 'card') {
window.location.href = res.formUrl;
} else {
setQrData(res);
setPollStatus('pending_payment');
}
} catch (err) {
console.error('SVLM: create booking failed', err);
setError(t.booking_error[lang]);
} finally {
setSubmitting(false);
}
}
if (qrData) {
return (
{t.booking_qr_title[lang]}
{pollStatus === 'pending_payment' && (
{bookingFormatThb(qrData.amount)}
{t.booking_qr_sub[lang]}
{t.booking_qr_waiting[lang]}
)}
{pollStatus === 'confirmed' && (
{t.booking_confirmed_title[lang]}
{t.booking_confirmed_sub[lang]}
{t.booking_back[lang]}
)}
{pollStatus === 'failed' && (
{t.booking_failed_title[lang]}
{t.booking_failed_sub[lang]}
{ setQrData(null); setError(''); }}>{t.booking_back[lang]}
)}
);
}
return (
{property.name}
{property.loc}
{t.booking_choose_term[lang]}
{leaseTerms.map(m => (
setSelectedTerm(m)} className="cust-chip" style={{ padding: '9px 16px', background: selectedTerm === m ? '#FF6A1A' : '#F5EEE8', color: selectedTerm === m ? '#fff' : '#7A6F68' }}>
{m} {t.booking_term_months[lang]}
))}
setMoveInDate(e.target.value)} />
{selectedTerm != null && (
{t.booking_monthly_rate[lang]}
{bookingFormatThb(monthlyRate)}/{t.booking_month[lang]}
{t.booking_deposit_label[lang]}
{bookingFormatThb(deposit)}
{t.booking_deposit_note[lang]}
)}
{t.booking_pay_method[lang]}
{[['qr', t.booking_pay_qr[lang]], ['card', t.booking_pay_card[lang]]].map(([key, label]) => (
setMethod(key)} className="cust-chip" style={{ flex: 1, textAlign: 'center', padding: '11px 4px', background: method === key ? '#201812' : '#F5EEE8', color: method === key ? '#fff' : '#7A6F68' }}>
{label}
))}
{!!error &&
{error}
}
{submitting ? '…' : `${t.booking_pay_now[lang]} — ${bookingFormatThb(deposit)}`}
);
}
/* ---------------- My Bookings ---------------- */
function CustMyBookings({ lang, bookings, loading, onClose }) {
const t = CUST_DICT;
return (
{t.my_bookings_title[lang]}
{loading ? (
…
) : bookings.length === 0 ? (
{t.my_bookings_empty[lang]}
) : (
{bookings.map(b => {
const badge = CUST_BOOKING_STATUS_STYLE[b.status] || CUST_BOOKING_STATUS_STYLE.pending_payment;
return (
{b.leaseTermMonths} {t.booking_term_months[lang]}
{new Date(b.createdAt).toLocaleDateString()}
{t['booking_status_' + b.status] ? t['booking_status_' + b.status][lang] : b.status}
{t.booking_deposit_label[lang]}
{bookingFormatThb(b.depositAmount)}
);
})}
)}
);
}
const CUST_BOOKING_STATUS_STYLE = {
pending_payment: { color: '#B3634A', bg: '#FFF1E6' },
confirmed: { color: '#2FAE66', bg: '#EAF6EE' },
failed: { color: '#C0392B', bg: '#FDEDEB' },
cancelled: { color: '#8A7C74', bg: '#F5EEE8' },
};
/* ---------------- Activities ---------------- */
// Real history of everything that's happened on this account -- the same
// feed that powers the notification bell (viewings booked, bookings
// confirmed, service requests, bill reminders, documents, listing status,
// chat, saved-search matches), just shown as a running list rather than a
// dismiss-once inbox. No separate fetch: notifications is already loaded by
// the tab-change effect that already covers this tab too.
function CustActivities({ lang, notifications, loading, onOpen }) {
const t = CUST_DICT;
return (
{t.activities_title[lang]}
{loading ? (
…
) : notifications.length === 0 ? (
{t.activities_empty[lang]}
) : (
{notifications.map(n => (
onOpen(n)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', cursor: 'pointer', borderBottom: '1px solid #F5EEE8' }}>
{formatChatTime(n.createdAt)}
))}
)}
);
}
/* ---------------- My Listings — properties this customer submitted via
"List your property", regardless of Ops's approval status. Rows aren't
tappable yet: a pending/rejected submission isn't visible on the public
detail endpoint (GET /listings/:id only serves live/reserved/sold), so
wiring these into the normal property-detail screen would 404 on the
common case. A dedicated "my listing detail" view is a reasonable next
step, not built here. ---------------- */
const CUST_LISTING_STATUS_STYLE = {
pending: { color: '#B3634A', bg: '#FFF1E6' },
live: { color: '#2FAE66', bg: '#EAF6EE' },
reserved: { color: '#2FAE66', bg: '#EAF6EE' },
sold: { color: '#7A6F68', bg: '#F5EEE8' },
rejected: { color: '#C0392B', bg: '#FDEDEB' },
draft: { color: '#7A6F68', bg: '#F5EEE8' },
};
function CustMyListings({ lang, listings, loading, onClose, openListProperty, onOpenDetail }) {
const t = CUST_DICT;
const STATUS_STYLE = CUST_LISTING_STATUS_STYLE;
return (
{t.my_listings_title[lang]}
{t.my_listings_cta[lang]}
{loading ? (
…
) : listings.length === 0 ? (
{t.my_listings_empty[lang]}
{t.my_listings_empty_sub[lang]}
) : (
{listings.map(l => {
const style = STATUS_STYLE[l.status] || STATUS_STYLE.draft;
return (
onOpenDetail(l.id)} style={{ display: 'flex', gap: 12, padding: 10, border: '1px solid #F1E6DC', borderRadius: 14, cursor: 'pointer' }}>
{l.coverUrl ? (
) : (
)}
{l.name}
{l.loc}
{l.price}
{(t['status_' + l.status] || t.status_draft)[lang]}
);
})}
)}
);
}
/* ---------------- Saved Search ---------------- */
function CustSavedSearches({ lang, searches, loading, onClose, onApply, onDelete }) {
const t = CUST_DICT;
return (
{t.saved_search_title[lang]}
{loading ? (
…
) : searches.length === 0 ? (
{t.saved_search_empty[lang]}
{t.saved_search_empty_sub[lang]}
) : (
{searches.map(s => (
onApply(s)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px', border: '1px solid #F1E6DC', borderRadius: 14, cursor: 'pointer' }}>
{s.name}
{formatChatTime(s.createdAt)}
{ e.stopPropagation(); onDelete(s.id); }} style={{ width: 30, height: 30, borderRadius: 999, background: '#F5EEE8', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, cursor: 'pointer' }}>
))}
)}
);
}
/* ---------------- Notifications ---------------- */
function CustNotifications({ lang, notifications, loading, unreadCount, onOpen, onMarkAllRead }) {
const t = CUST_DICT;
return (
{t.notif_title[lang]}
{unreadCount > 0 &&
{t.notif_mark_all_read[lang]}
}
{loading ? (
…
) : notifications.length === 0 ? (
{t.notif_empty[lang]}
{t.notif_empty_sub[lang]}
) : (
{notifications.map(n => (
onOpen(n)} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px 0', cursor: 'pointer', borderBottom: '1px solid #F5EEE8' }}>
{n.title}
{n.body}
{formatChatTime(n.createdAt)}
{!n.readAt &&
}
))}
)}
);
}
/* ---------------- Menu — lean settings-style entry point. "Manage account"
opens CustProfile (avatar/contact, general settings, notifications,
contact us). ---------------- */
function CustMenu({ lang, userName, userEmail, onOpenAccount, onOpenMyListings, onOpenSavedSearches, onOpenTenantPortal, onOpenMyBookings, onToggleLang, onSignOut }) {
const t = CUST_DICT;
function openPrivacyPolicy() {
window.open('https://apphome.svlm.co.th/privacy.html', '_blank');
}
const items = [
{ icon: 'list', label: t.menu_my_listings[lang], onClick: onOpenMyListings },
{ icon: 'calendar', label: t.menu_my_bookings[lang], onClick: onOpenMyBookings },
{ icon: 'home', label: t.menu_juristic[lang], onClick: onOpenTenantPortal },
{ icon: 'heart', label: t.menu_saved_search[lang], onClick: onOpenSavedSearches },
{ icon: 'globe', label: t.menu_language[lang], value: lang === 'en' ? 'English' : 'ไทย', onClick: onToggleLang },
{ icon: 'shield', label: t.menu_privacy_policy[lang], onClick: openPrivacyPolicy },
{ icon: 'user', label: t.menu_manage_account[lang], onClick: onOpenAccount },
];
return (
{t.nav_menu[lang]}
{t.menu_welcome[lang]}
{items.map((it, i) => (
{it.label}
{!!it.value && {it.value} }
))}
{t.menu_logout[lang]}
Version 1 by SVLM Thailand
);
}
/* ---------------- Tab bar ---------------- */
function CustTabBar({ lang, tab, goHome, goSearch, goActivities, goChat, goNotification, goMenu, messageBadge, notificationBadge }) {
const t = CUST_DICT;
const tabs = [
{ key: 'home', label: t.nav_home, icon: 'home', go: goHome },
{ key: 'search', label: t.nav_listings, icon: 'list', go: goSearch },
{ key: 'activities', label: t.nav_activities, icon: 'dash', go: goActivities },
{ key: 'chat', label: t.nav_message, icon: 'chat', go: goChat, badge: messageBadge },
{ key: 'notification', label: t.nav_notification, icon: 'bell', go: goNotification, badge: notificationBadge },
{ key: 'menu', label: t.nav_menu, icon: 'menu', go: goMenu },
];
return (
{tabs.map(tb => {
const active = tab === tb.key || (tb.key === 'home' && tab === 'shortterm');
const color = active ? '#FF6A1A' : '#B3A89F';
return (
{!!tb.badge &&
{tb.badge}
}
{tb.label[lang]}
);
})}
);
}
/* ---------------- Root app ---------------- */
function CustApp() {
const [screen, setScreen] = useState(SvlmApi.isCustomerLoggedIn() ? 'app' : 'onboarding'); // onboarding | login | app
// LINE login is a full-page redirect, so the whole app remounts on the way back —
// at that exact instant isCustomerLoggedIn() is still false (login hasn't happened
// yet), so `screen` above starts on onboarding, and the login-completion logic that
// normally lives in CustLogin's own effect never runs because CustLogin never mounts
// while onboarding is showing. Checking here instead, once, regardless of which
// screen is up, so a returning LIFF redirect jumps straight to the app instead of
// stranding the customer on onboarding until they click through it a second time.
useEffect(() => {
if (SvlmApi.isCustomerLoggedIn()) return;
let cancelled = false;
ensureLiffInit().then(() => {
if (cancelled || !window.liff.isLoggedIn()) return;
const idToken = window.liff.getIDToken();
if (!idToken) return;
return SvlmApi.customerLoginLine(idToken).then(() => { if (!cancelled) setScreen('app'); });
}).catch(() => { /* not a LIFF redirect return, or LIFF unavailable — normal onboarding/login proceeds */ });
return () => { cancelled = true; };
}, []);
const [onboardStep, setOnboardStep] = useState(0);
const [lang, setLang] = useState('en');
const [tab, setTab] = useState('home');
const [homeMode, setHomeMode] = useState('buy');
const [searchMode, setSearchMode] = useState('list');
const [filterDeal, setFilterDeal] = useState('');
const [filterType, setFilterType] = useState('');
const [filterPriceMin, setFilterPriceMin] = useState('');
const [filterPriceMax, setFilterPriceMax] = useState('');
const [filterAreaMin, setFilterAreaMin] = useState('');
const [filterAreaMax, setFilterAreaMax] = useState('');
const [filterFloorMin, setFilterFloorMin] = useState('');
const [filterFloorMax, setFilterFloorMax] = useState('');
const [filterDistrict, setFilterDistrict] = useState('');
const [filterTransit, setFilterTransit] = useState([]);
const toggleFilterTransit = (station) => setFilterTransit(list => list.includes(station) ? list.filter(x => x !== station) : [...list, station]);
const [districtOptions, setDistrictOptions] = useState([]);
const [filterBeds, setFilterBeds] = useState(0);
const [filterPet, setFilterPet] = useState('any');
const [showFilterPanel, setShowFilterPanel] = useState(false);
const [selectedLocation, setSelectedLocation] = useState(null);
const [showProvincePicker, setShowProvincePicker] = useState(false);
const [provinceSearch, setProvinceSearch] = useState('');
const [selectedPropertyId, setSelectedPropertyId] = useState(null);
const [detailProperty, setDetailProperty] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [projectReviews, setProjectReviews] = useState([]);
const [selectedProjectReviewId, setSelectedProjectReviewId] = useState(null);
const [detailProjectReview, setDetailProjectReview] = useState(null);
const [chatView, setChatView] = useState('list');
const [conversations, setConversations] = useState([]);
const [conversationsLoading, setConversationsLoading] = useState(false);
const [chatSearchQuery, setChatSearchQuery] = useState('');
const [activeConversationId, setActiveConversationId] = useState(null);
const [activeConversation, setActiveConversation] = useState(null);
const [activeMessages, setActiveMessages] = useState([]);
const [activeMessagesLoading, setActiveMessagesLoading] = useState(false);
const [messageInput, setMessageInput] = useState('');
const [sendingMessage, setSendingMessage] = useState(false);
const [startingChat, setStartingChat] = useState(false);
const [notifications, setNotifications] = useState([]);
const [notificationsLoading, setNotificationsLoading] = useState(false);
const [notificationsUnreadCount, setNotificationsUnreadCount] = useState(0);
const [savedSearches, setSavedSearches] = useState([]);
const [savedSearchesLoading, setSavedSearchesLoading] = useState(false);
const [showSavedSearches, setShowSavedSearches] = useState(false);
const [showListProperty, setShowListProperty] = useState(false);
// Set only when this ListProperty screen was reached via the availability-
// confirm flow's "I have another property to sell" handoff (?owner_submit_token=)
// rather than the logged-in "List your property" button -- bypasses the
// login gate and switches submitListing to the public owner-token endpoint.
const [ownerSubmitToken, setOwnerSubmitToken] = useState(null);
const [showGeneralInquire, setShowGeneralInquire] = useState(false);
const [listSubmitted, setListSubmitted] = useState(false);
const [listSubmitting, setListSubmitting] = useState(false);
const [listSubmitError, setListSubmitError] = useState('');
const [showMyListings, setShowMyListings] = useState(false);
const [myListings, setMyListings] = useState([]);
const [myListingsLoading, setMyListingsLoading] = useState(false);
const [showMyListingDetail, setShowMyListingDetail] = useState(false);
const [myListingDetail, setMyListingDetail] = useState(null);
const [myListingDetailLoading, setMyListingDetailLoading] = useState(false);
const [myProfile, setMyProfile] = useState(null);
const [showProfile, setShowProfile] = useState(false);
const [showEditProfile, setShowEditProfile] = useState(false);
const [editProfileSaving, setEditProfileSaving] = useState(false);
const [editProfileError, setEditProfileError] = useState('');
const [showChangePassword, setShowChangePassword] = useState(false);
const [showPaymentMethods, setShowPaymentMethods] = useState(false);
const [showSocialAccounts, setShowSocialAccounts] = useState(false);
const [showContactUs, setShowContactUs] = useState(false);
const [paymentMethods, setPaymentMethods] = useState([]);
const [paymentMethodsLoading, setPaymentMethodsLoading] = useState(false);
const [showAddCardResult, setShowAddCardResult] = useState(false);
const [addCardResultStatus, setAddCardResultStatus] = useState(null); // null (pending) | 'success' | 'fail'
const [showBookingCheckout, setShowBookingCheckout] = useState(false);
const [bookingProperty, setBookingProperty] = useState(null);
const [showMyBookings, setShowMyBookings] = useState(false);
const [myBookings, setMyBookings] = useState([]);
const [myBookingsLoading, setMyBookingsLoading] = useState(false);
const [showBookingResult, setShowBookingResult] = useState(false);
const [bookingResultStatus, setBookingResultStatus] = useState(null); // null (pending) | 'success' | 'fail'
const [pushEnabled, setPushEnabled] = useState(() => { try { return localStorage.getItem('svlm_cust_push_notif') !== 'off'; } catch (e) { return true; } });
const [promoEnabled, setPromoEnabled] = useState(() => { try { return localStorage.getItem('svlm_cust_promo_notif') !== 'off'; } catch (e) { return true; } });
const [calcConfig, setCalcConfig] = useState(null); // { tool, loanAmount } | null
const [showInsurance, setShowInsurance] = useState(false);
const [showTenantPortal, setShowTenantPortal] = useState(false);
const toggleLang = () => setLang(l => l === 'en' ? 'th' : 'en');
function openCalculator(loanAmount) {
setCalcConfig({ tool: 'mortgage', loanAmount: loanAmount || '' });
}
const openFilters = () => setShowFilterPanel(true);
const closeFilters = () => setShowFilterPanel(false);
function clearFilters() {
setFilterType('');
setFilterPriceMin('');
setFilterPriceMax('');
setFilterAreaMin('');
setFilterAreaMax('');
setFilterFloorMin('');
setFilterFloorMax('');
setFilterDistrict('');
setFilterTransit([]);
setFilterBeds(0);
setFilterPet('any');
}
async function loadSavedSearches() {
setSavedSearchesLoading(true);
try {
const items = await SvlmApi.fetchMySavedSearches();
setSavedSearches(items);
} catch (err) {
console.error('SVLM: saved searches failed', err);
} finally {
setSavedSearchesLoading(false);
}
}
const openSavedSearches = () => { setShowSavedSearches(true); loadSavedSearches(); };
const closeSavedSearches = () => setShowSavedSearches(false);
function buildSavedSearchName() {
const parts = [];
parts.push(filterType ? ((CUST_LIST_CATEGORY_KEYS.find(c => c.en === filterType) || {})[lang] || filterType) : CUST_DICT.saved_search_all_types[lang]);
if (selectedLocation) parts.push(selectedLocation);
if (filterPriceMin || filterPriceMax) parts.push('฿' + (filterPriceMin || '0') + (filterPriceMax ? '-' + filterPriceMax : '+'));
return parts.join(' · ');
}
async function saveCurrentSearch() {
const filters = {
searchQuery, selectedLocation, filterType, filterPriceMin, filterPriceMax,
filterAreaMin, filterAreaMax, filterFloorMin, filterFloorMax, filterDistrict, filterBeds, filterPet,
};
try {
await SvlmApi.createSavedSearch(buildSavedSearchName(), filters);
window.alert(CUST_DICT.saved_search_saved[lang]);
} catch (err) {
console.error('SVLM: save search failed', err);
}
}
function applySavedSearch(s) {
const f = s.filters || {};
setSearchQuery(f.searchQuery || '');
setSelectedLocation(f.selectedLocation || null);
setFilterType(f.filterType || '');
setFilterPriceMin(f.filterPriceMin || '');
setFilterPriceMax(f.filterPriceMax || '');
setFilterAreaMin(f.filterAreaMin || '');
setFilterAreaMax(f.filterAreaMax || '');
setFilterFloorMin(f.filterFloorMin || '');
setFilterFloorMax(f.filterFloorMax || '');
setFilterDistrict(f.filterDistrict || '');
setFilterBeds(f.filterBeds || 0);
setFilterPet(f.filterPet || 'any');
setShowSavedSearches(false);
setTab('search');
setSelectedPropertyId(null);
}
async function removeSavedSearch(id) {
setSavedSearches(list => list.filter(s => s.id !== id));
try {
await SvlmApi.deleteSavedSearch(id);
} catch (err) {
console.error('SVLM: delete saved search failed', err);
loadSavedSearches();
}
}
const goHome = () => { setTab('home'); setSelectedPropertyId(null); setChatView('list'); };
const goSearch = () => { setTab('search'); setSelectedPropertyId(null); };
const goSearchMap = () => { setSearchMode('map'); setTab('search'); setSelectedPropertyId(null); };
const openLocationSearch = (area) => { setSearchQuery(area); setTab('search'); setSelectedPropertyId(null); };
const openCategorySearch = (categoryType) => { setFilterType(categoryType); setTab('search'); setSelectedPropertyId(null); };
const goShortTerm = () => { setTab('shortterm'); setSelectedPropertyId(null); };
const goChat = () => { setTab('chat'); setSelectedPropertyId(null); setChatView('list'); };
const goActivities = () => { setTab('activities'); setSelectedPropertyId(null); };
const goNotification = () => { setTab('notification'); setSelectedPropertyId(null); };
const goMenu = () => { setTab('menu'); setSelectedPropertyId(null); };
// Fetches the property directly rather than looking it up in `properties` —
// that list only ever holds whatever page/filter is currently loaded, so a
// shared link (or any property that's scrolled out of the loaded set) would
// otherwise resolve to nothing.
async function openProperty(id) {
setSelectedPropertyId(id);
setDetailLoading(true);
try {
const l = await SvlmApi.fetchPublicListing(id);
setDetailProperty({ ...formatCustListing(l, lang), mortgage: estimateMortgage(l.deal === 'sale' ? Number(l.price) : null) });
trackPixel('ViewContent', { content_ids: [id], content_type: 'product' });
} catch (err) {
console.error('SVLM: fetch property detail failed', err);
setDetailProperty(properties.find(p => p.id === id) || null);
} finally {
setDetailLoading(false);
}
}
const closeDetail = () => { setSelectedPropertyId(null); setDetailProperty(null); };
async function openProjectReview(id) {
setSelectedProjectReviewId(id);
try {
const p = await SvlmApi.fetchPublicProjectReview(id);
setDetailProjectReview(formatCustProjectReview(p, lang));
} catch (err) {
console.error('SVLM: fetch project review detail failed', err);
setDetailProjectReview(projectReviews.find(p => p.id === id) || null);
}
}
const closeProjectReviewDetail = () => { setSelectedProjectReviewId(null); setDetailProjectReview(null); };
async function shareProperty(id, name) {
const url = window.location.origin + window.location.pathname + '?property=' + id;
if (navigator.share) {
try { await navigator.share({ title: name, url }); } catch (e) { /* user cancelled — not an error */ }
} else if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(url);
window.alert(lang === 'en' ? 'Link copied to clipboard!' : 'คัดลอกลิงก์แล้ว!');
} catch (e) { console.error('SVLM: copy link failed', e); }
}
}
async function shareAppReferral() {
const url = 'https://appagent.svlm.co.th';
const text = CUST_DICT.refer_share_text[lang];
if (navigator.share) {
try { await navigator.share({ title: CUST_DICT.refer_share_title[lang], text, url }); } catch (e) { /* user cancelled — not an error */ }
} else if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(text + ' ' + url);
window.alert(lang === 'en' ? 'Link copied to clipboard!' : 'คัดลอกลิงก์แล้ว!');
} catch (e) { console.error('SVLM: copy referral link failed', e); }
}
}
async function loadConversations() {
setConversationsLoading(true);
try {
const items = await SvlmApi.fetchMyConversations();
setConversations(items);
} catch (err) {
console.error('SVLM: conversations failed', err);
} finally {
setConversationsLoading(false);
}
}
async function openThread(id) {
setActiveConversationId(id);
setChatView('thread');
setMessageInput('');
setActiveMessagesLoading(true);
try {
const data = await SvlmApi.fetchMyConversationMessages(id);
setActiveConversation(data.conversation);
setActiveMessages(data.items);
} catch (err) {
console.error('SVLM: messages failed', err);
setActiveConversation(null);
setActiveMessages([]);
} finally {
setActiveMessagesLoading(false);
}
}
const closeThread = () => { setChatView('list'); setActiveConversationId(null); loadConversations(); };
// "Chat" on a property detail page — get-or-create the conversation with
// that listing's assigned agent (there's no agent to message if Ops
// hasn't assigned one yet, which openThreadFromDetail surfaces as an error).
async function openThreadFromDetail() {
if (!SvlmApi.isCustomerLoggedIn()) { setScreen('login'); return; }
const listingId = selectedProperty && selectedProperty.id;
if (!listingId) return;
setSelectedPropertyId(null);
setTab('chat');
setChatView('thread');
setStartingChat(true);
try {
const { id } = await SvlmApi.startMyConversation(listingId);
await openThread(id);
} catch (err) {
console.error('SVLM: start chat failed', err);
window.alert(err && err.message === 'This listing has no agent assigned yet' ? CUST_DICT.chat_no_agent[lang] : CUST_DICT.chat_start_error[lang]);
setChatView('list');
} finally {
setStartingChat(false);
}
}
async function sendChatMessage(overrideText) {
const text = (overrideText != null ? overrideText : messageInput).trim();
if (!text || sendingMessage || !activeConversationId) return;
setMessageInput('');
setSendingMessage(true);
try {
await SvlmApi.sendMyConversationMessage(activeConversationId, text);
} catch (err) {
console.error('SVLM: send message failed', err);
} finally {
setSendingMessage(false);
}
}
async function loadNotifications() {
setNotificationsLoading(true);
try {
const data = await SvlmApi.fetchMyNotifications();
setNotifications(data.items || []);
setNotificationsUnreadCount(data.unreadCount || 0);
} catch (err) {
console.error('SVLM: notifications failed', err);
} finally {
setNotificationsLoading(false);
}
}
function openNotification(n) {
if (!n.readAt) {
setNotifications(list => list.map(x => x.id === n.id ? { ...x, readAt: new Date().toISOString() } : x));
setNotificationsUnreadCount(c => Math.max(0, c - 1));
SvlmApi.markNotificationRead(n.id).catch(err => console.error('SVLM: mark notification read failed', err));
}
if (n.type === 'chat_message' && n.relatedId) {
setTab('chat');
openThread(n.relatedId);
} else if (n.type === 'listing_status' && n.relatedId) {
openMyListingDetail(n.relatedId);
} else if (n.type === 'saved_search_match' && n.relatedId) {
openProperty(n.relatedId);
} else if (n.type === 'booking_status') {
openMyBookings();
} else if (n.type === 'service_request_update' || n.type === 'bill_reminder' || n.type === 'document_ready') {
openTenantPortal();
}
}
async function markAllNotificationsRead() {
setNotifications(list => list.map(x => ({ ...x, readAt: x.readAt || new Date().toISOString() })));
setNotificationsUnreadCount(0);
try {
await SvlmApi.markAllNotificationsRead();
} catch (err) {
console.error('SVLM: mark all notifications read failed', err);
}
}
const openListProperty = () => {
// A shared-property link (?property=) drops visitors straight into
// screen='app' without ever running the OTP login, so customerToken can
// be unset here even though the rest of the UI looks fully logged in —
// send them to log in first instead of letting them fill out the whole
// form only to hit a silent "Not logged in" failure at the very end.
if (!SvlmApi.isCustomerLoggedIn()) { setScreen('login'); return; }
setShowListProperty(true); setListSubmitted(false); setListSubmitError('');
};
const closeListProperty = () => { setShowListProperty(false); setOwnerSubmitToken(null); };
async function loadMyListings() {
setMyListingsLoading(true);
try {
const items = await SvlmApi.fetchMyListingsAsCustomer();
setMyListings(items.map(l => ({ ...formatCustListing(l, lang), status: l.status })));
} catch (err) {
console.error('SVLM: my listings failed', err);
} finally {
setMyListingsLoading(false);
}
}
const openMyListings = () => { setShowMyListings(true); loadMyListings(); };
const openTenantPortal = () => setShowTenantPortal(true);
const closeMyListings = () => setShowMyListings(false);
async function openMyListingDetail(id) {
setShowMyListingDetail(true);
setMyListingDetailLoading(true);
try {
const l = await SvlmApi.fetchMyListingDetailAsCustomer(id);
setMyListingDetail({ ...formatCustListing(l, lang), status: l.status });
} catch (err) {
console.error('SVLM: my listing detail failed', err);
setMyListingDetail(null);
} finally {
setMyListingDetailLoading(false);
}
}
const closeMyListingDetail = () => { setShowMyListingDetail(false); setMyListingDetail(null); };
const openGeneralInquire = () => {
if (!SvlmApi.isCustomerLoggedIn()) { setScreen('login'); return; }
setShowGeneralInquire(true);
};
const closeGeneralInquire = () => setShowGeneralInquire(false);
async function submitListing(form) {
setListSubmitting(true);
setListSubmitError('');
try {
// The owner-token path has no customer session, so photo upload (which
// requires one) is skipped here -- Ops can add photos during the
// approval review, same as any other detail they'd fill in for a
// bare-bones submission.
let photoUrls;
if (form.photos.length && !ownerSubmitToken) {
photoUrls = await SvlmApi.uploadPhotosAsCustomer(form.photos);
}
const province = provinceToThai(form.province);
const deals = [];
if (form.dealSale) deals.push({ deal: 'sale', price: Number(form.salePrice) || 0 });
if (form.dealRent) deals.push({ deal: 'rent', price: Number(form.rentPrice) || 0 });
const shortTerm = form.dealRent && form.shortTermEnabled && form.leaseTerms && form.leaseTerms.length;
const payload = {
title: { th: form.title },
description: form.description || undefined,
type: form.type,
deal: deals[0].deal,
price: deals[0].price,
deals: deals.length > 1 ? deals : undefined,
rentalTerm: shortTerm ? 'short' : undefined,
leaseTerms: shortTerm ? form.leaseTerms : undefined,
monthlyPrices: shortTerm ? Object.fromEntries(form.leaseTerms.map(m => [m, Number(form.monthlyPrices[m])])) : undefined,
area: form.area ? Number(form.area) : undefined,
far: form.far ? Number(form.far) : undefined,
beds: form.beds ? Number(form.beds) : undefined,
baths: form.baths ? Number(form.baths) : undefined,
petPolicy: form.petPolicy || undefined,
province,
district: form.address || undefined,
unitNumber: form.unitNumber || undefined,
buildingName: form.buildingName || undefined,
nearbyTransit: form.nearbyTransit && form.nearbyTransit.length ? form.nearbyTransit : undefined,
amenityIds: form.amenityIds && form.amenityIds.length ? form.amenityIds : undefined,
lat: form.lat,
lng: form.lng,
ownerName: form.ownerName || undefined,
ownerPhone: form.ownerPhone || undefined,
ownerLineId: form.ownerLineId || undefined,
ownerEmail: form.ownerEmail || undefined,
ownerWhatsapp: form.ownerWhatsapp || undefined,
ownerFacebook: form.ownerFacebook || undefined,
soleAgency: form.soleAgency || undefined,
exclusivePeriodMonths: form.exclusivePeriodMonths || undefined,
...(photoUrls ? { photoUrls } : {}),
};
if (ownerSubmitToken) {
await SvlmApi.submitListingAsOwnerToken(ownerSubmitToken, payload);
} else {
await SvlmApi.submitListingAsCustomer(payload);
loadMyListings();
}
setListSubmitted(true);
} catch (err) {
console.error('SVLM: submit listing failed', err);
if (err && err.message === 'Not logged in') {
setShowListProperty(false);
setScreen('login');
} else {
setListSubmitError(lang === 'en' ? 'Could not submit. Please try again.' : 'ส่งข้อมูลไม่สำเร็จ กรุณาลองใหม่');
}
} finally {
setListSubmitting(false);
}
}
const openProvincePicker = () => { setShowProvincePicker(true); setProvinceSearch(''); };
const closeProvincePicker = () => setShowProvincePicker(false);
const selectProvince = (label) => { setSelectedLocation(label); setShowProvincePicker(false); };
const signOut = () => { SvlmApi.customerLogout(); setScreen('onboarding'); setOnboardStep(0); setTab('home'); };
const [properties, setProperties] = useState([]);
const [propsTotal, setPropsTotal] = useState(0);
const [propsPage, setPropsPage] = useState(1);
const [loadingMoreProps, setLoadingMoreProps] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [promotions, setPromotions] = useState([]);
const [shortTermListings, setShortTermListings] = useState([]);
const [shortTermLoading, setShortTermLoading] = useState(false);
useEffect(() => {
SvlmApi.fetchPromotions().then(setPromotions).catch(err => console.error('SVLM: promotions failed', err));
}, []);
// Short-term rentals aren't a separate deal type on the backend — they're
// regular 'rent' listings with rentalTerm 'short' or 'both', so the filter
// is applied client-side rather than adding a new query param the backend
// doesn't already support.
useEffect(() => {
if (tab !== 'shortterm') return;
setShortTermLoading(true);
SvlmApi.fetchPublicListings({ deal: 'rent', pageSize: 50 })
.then(res => {
const items = (res.items || [])
.filter(l => l.rentalTerm === 'short' || l.rentalTerm === 'both')
.map(l => formatCustListing(l, lang));
setShortTermListings(items);
})
.catch(err => console.error('SVLM: short-term listings failed', err))
.finally(() => setShortTermLoading(false));
}, [tab, lang]);
useEffect(() => {
if (screen !== 'app' || !SvlmApi.isCustomerLoggedIn()) return;
SvlmApi.fetchMeAsCustomer().then(setMyProfile).catch(err => console.error('SVLM: profile failed', err));
}, [screen]);
useEffect(() => {
// Also fires on tab changes (not just once on screen entry) so the tab
// bar's unread badge is accurate even if it was updated from another
// device/session, not just from this session's own realtime pushes.
if (screen !== 'app' || !SvlmApi.isCustomerLoggedIn()) return;
loadNotifications();
// eslint-disable-next-line
}, [screen, tab]);
// Live delivery for the logged-in user's own notifications — a new chat
// reply or a listing status change pushes here immediately rather than
// only showing up next time the Notification tab happens to be opened.
const myUserId = myProfile ? myProfile.id : null;
useEffect(() => {
if (screen !== 'app' || !myUserId) return;
const socket = SvlmApi.connectRealtime('user:' + myUserId, {
'notification.created': (payload) => {
setNotifications(list => [payload, ...list]);
setNotificationsUnreadCount(c => c + 1);
},
});
return () => socket.disconnect();
}, [screen, myUserId]);
useEffect(() => {
if (screen !== 'app' || tab !== 'chat' || chatView !== 'list' || !SvlmApi.isCustomerLoggedIn()) return;
loadConversations();
// eslint-disable-next-line
}, [screen, tab, chatView]);
// Live delivery for whichever thread is currently open — the room is
// shared with the agent app's own send(), so a reply shows up here
// without polling or a manual refresh.
useEffect(() => {
if (screen !== 'app' || chatView !== 'thread' || !activeConversationId) return;
const socket = SvlmApi.connectRealtime('conversation:' + activeConversationId, {
'chat:message': (payload) => {
if (payload.conversationId !== activeConversationId) return;
setActiveMessages(list => [...list, { fromAgent: payload.fromAgent, body: payload.body, createdAt: payload.createdAt }]);
},
});
return () => socket.disconnect();
}, [screen, chatView, activeConversationId]);
const openProfile = () => setShowProfile(true);
const closeProfile = () => setShowProfile(false);
const openEditProfile = () => { setEditProfileError(''); setShowEditProfile(true); };
const closeEditProfile = () => setShowEditProfile(false);
const openChangePassword = () => setShowChangePassword(true);
const closeChangePassword = () => setShowChangePassword(false);
const openComingSoon = () => window.alert(CUST_DICT.profile_coming_soon[lang]);
const openInsurance = () => setShowInsurance(true);
function togglePush(next) {
setPushEnabled(next);
try { localStorage.setItem('svlm_cust_push_notif', next ? 'on' : 'off'); } catch (e) { /* private mode — in-memory only */ }
}
function togglePromo(next) {
setPromoEnabled(next);
try { localStorage.setItem('svlm_cust_promo_notif', next ? 'on' : 'off'); } catch (e) { /* private mode — in-memory only */ }
}
const openContactUsFromProfile = () => setShowContactUs(true);
const closeContactUs = () => setShowContactUs(false);
function loadPaymentMethods() {
setPaymentMethodsLoading(true);
SvlmApi.fetchMyPaymentMethods().then(setPaymentMethods).catch(err => console.error('SVLM: payment methods failed', err)).finally(() => setPaymentMethodsLoading(false));
}
const openPaymentMethods = () => { setShowPaymentMethods(true); loadPaymentMethods(); };
const closePaymentMethods = () => setShowPaymentMethods(false);
const openSocialAccounts = () => setShowSocialAccounts(true);
const closeSocialAccounts = () => setShowSocialAccounts(false);
async function startAddCard() {
try {
const { formUrl } = await SvlmApi.initiateAddCard();
window.location.href = formUrl;
} catch (err) {
console.error('SVLM: initiate add card failed', err);
window.alert(lang === 'en' ? 'Could not start adding a card. Please try again.' : 'ไม่สามารถเริ่มเพิ่มบัตรได้ กรุณาลองใหม่');
}
}
async function removePaymentMethodById(id) {
try {
await SvlmApi.deletePaymentMethod(id);
setPaymentMethods(list => list.filter(c => c.id !== id));
} catch (err) {
console.error('SVLM: remove payment method failed', err);
}
}
const closeAddCardResult = () => { setShowAddCardResult(false); setShowProfile(true); setShowPaymentMethods(true); loadPaymentMethods(); };
function openBookingCheckout(property) { setBookingProperty(property); setShowBookingCheckout(true); }
const closeBookingCheckout = () => { setShowBookingCheckout(false); setBookingProperty(null); };
function loadMyBookings() {
setMyBookingsLoading(true);
SvlmApi.fetchMyShortTermBookings().then(setMyBookings).catch(err => console.error('SVLM: my bookings failed', err)).finally(() => setMyBookingsLoading(false));
}
const openMyBookings = () => { setShowMyBookings(true); loadMyBookings(); };
const closeMyBookings = () => setShowMyBookings(false);
const closeBookingResult = () => { setShowBookingResult(false); closeBookingCheckout(); setShowMyBookings(true); loadMyBookings(); };
// mPay redirects the browser back here (full page reload) after the customer
// completes or cancels adding a card, or paying a booking deposit by card —
// pick up the result from the URL once, confirm the real status with our own
// backend (never trust the URL alone), then scrub the query string so a
// refresh doesn't replay it.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const addCardResult = params.get('add_card_result');
const orderId = params.get('order_id');
if (addCardResult && orderId) {
window.history.replaceState(null, '', window.location.pathname);
setShowAddCardResult(true);
setAddCardResultStatus(null);
if (addCardResult !== 'success') { setAddCardResultStatus('fail'); return; }
SvlmApi.confirmAddCard(orderId)
.then(res => setAddCardResultStatus(res && res.ok ? 'success' : 'fail'))
.catch(err => { console.error('SVLM: confirm add card failed', err); setAddCardResultStatus('fail'); });
return;
}
const bookingResult = params.get('booking_result');
const bookingId = params.get('booking_id');
if (bookingResult && bookingId) {
window.history.replaceState(null, '', window.location.pathname);
setShowBookingResult(true);
setBookingResultStatus(null);
if (bookingResult !== 'success') { setBookingResultStatus('fail'); return; }
SvlmApi.fetchShortTermBookingStatus(bookingId)
.then(res => setBookingResultStatus(res && res.status === 'confirmed' ? 'success' : 'fail'))
.catch(err => { console.error('SVLM: confirm booking failed', err); setBookingResultStatus('fail'); });
}
}, []);
async function saveEditProfile(updates) {
setEditProfileSaving(true);
setEditProfileError('');
try {
const updated = await SvlmApi.updateMyProfileAsCustomer(updates);
setMyProfile(updated);
setShowEditProfile(false);
} catch (err) {
console.error('SVLM: update profile failed', err);
setEditProfileError(lang === 'en' ? 'Could not save. Please try again.' : 'บันทึกไม่สำเร็จ กรุณาลองใหม่');
} finally {
setEditProfileSaving(false);
}
}
useEffect(() => {
SvlmApi.fetchPublicProjectReviews()
.then(items => setProjectReviews(items.map(p => formatCustProjectReview(p, lang))))
.catch(err => console.error('SVLM: project reviews failed', err));
}, [lang]);
function buildListingsQuery(page) {
const query = { pageSize: 20, page };
if (searchQuery.trim()) query.q = searchQuery.trim();
const province = provinceToThai(selectedLocation);
if (province) query.province = province;
if (filterDeal) query.deal = filterDeal;
if (filterType) query.type = filterType;
if (filterPriceMin) query.priceMin = filterPriceMin;
if (filterPriceMax) query.priceMax = filterPriceMax;
if (filterAreaMin) query.areaMin = filterAreaMin;
if (filterAreaMax) query.areaMax = filterAreaMax;
if (filterFloorMin) query.floorMin = filterFloorMin;
if (filterFloorMax) query.floorMax = filterFloorMax;
if (filterDistrict.trim()) query.district = filterDistrict.trim();
if (filterTransit.length) query.nearbyTransit = filterTransit.join(',');
if (filterBeds) query.beds = filterBeds;
if (filterPet !== 'any') query.petFriendly = filterPet === 'yes' ? 'true' : 'false';
return query;
}
// A shared property link (?property=ID) must open directly — even before
// login — or sharing a listing is pointless. Runs once on mount only.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sharedId = params.get('property');
if (sharedId) {
setScreen('app');
openProperty(sharedId);
}
const sharedProjectId = params.get('project');
if (sharedProjectId) {
setScreen('app');
openProjectReview(sharedProjectId);
}
// Owner clicked "I have another property to sell" on the availability-
// confirm page -- open ListProperty directly, deliberately bypassing
// openListProperty()'s login gate (there is no customer session here,
// the confirmation token is the credential instead; see submitListing).
const ownerSubmitTok = params.get('owner_submit_token');
if (ownerSubmitTok) {
setScreen('app');
setOwnerSubmitToken(ownerSubmitTok);
setShowListProperty(true);
setListSubmitted(false);
setListSubmitError('');
}
// eslint-disable-next-line
}, []);
useEffect(() => {
const timer = setTimeout(() => {
setPropsPage(1);
SvlmApi.fetchPublicListings(buildListingsQuery(1))
.then(d => {
setProperties(d.items.map(l => ({ ...formatCustListing(l, lang), mortgage: estimateMortgage(l.deal === 'sale' ? Number(l.price) : null) })));
setPropsTotal(d.total || 0);
})
.catch(err => console.error('SVLM: listings failed', err));
}, 300);
return () => clearTimeout(timer);
}, [searchQuery, selectedLocation, lang, filterDeal, filterType, filterPriceMin, filterPriceMax, filterAreaMin, filterAreaMax, filterFloorMin, filterFloorMax, filterDistrict, filterTransit, filterBeds, filterPet]);
// District picker options — real values only, scoped to the selected province.
useEffect(() => {
const province = provinceToThai(selectedLocation);
SvlmApi.fetchDistricts(province).then(setDistrictOptions).catch(err => console.error('SVLM: districts failed', err));
}, [selectedLocation]);
async function loadMoreProperties() {
if (loadingMoreProps || properties.length >= propsTotal) return;
setLoadingMoreProps(true);
try {
const nextPage = propsPage + 1;
const d = await SvlmApi.fetchPublicListings(buildListingsQuery(nextPage));
setProperties(prev => [...prev, ...d.items.map(l => ({ ...formatCustListing(l, lang), mortgage: estimateMortgage(l.deal === 'sale' ? Number(l.price) : null) }))]);
setPropsTotal(d.total || 0);
setPropsPage(nextPage);
} catch (err) {
console.error('SVLM: load more listings failed', err);
} finally {
setLoadingMoreProps(false);
}
}
const hotDeals = properties.slice(0, 5);
const recommend = properties.slice(5, 15);
const featuredProperty = properties[0] || null;
const selectedProperty = selectedPropertyId ? detailProperty : null;
const selectedProjectReview = selectedProjectReviewId ? detailProjectReview : null;
const filteredConversations = chatSearchQuery.trim()
? conversations.filter(c => c.agentName.toLowerCase().includes(chatSearchQuery.trim().toLowerCase()))
: conversations;
const formattedMessages = activeMessages.map(m => ({
text: m.body, align: m.fromAgent ? 'flex-start' : 'flex-end',
bg: m.fromAgent ? '#F5EEE8' : '#FF6A1A', color: m.fromAgent ? '#201812' : '#fff',
}));
const showDetail = !!selectedPropertyId;
const showProjectReviewDetail = !!selectedProjectReviewId;
const showTabBar = !showDetail && !showProjectReviewDetail && chatView !== 'thread' && !showListProperty && !showMyListings && !showMyListingDetail && !showProfile && !showEditProfile && !showChangePassword && !showPaymentMethods && !showSocialAccounts && !showContactUs && !showBookingCheckout && !showMyBookings && !showSavedSearches && !showProvincePicker && !showFilterPanel && !calcConfig && !showTenantPortal;
let body;
if (screen === 'onboarding') {
body = setScreen('login')} />;
} else if (screen === 'login') {
body = setScreen('app')} />;
} else if (tab === 'home') {
body = ;
} else if (tab === 'shortterm') {
body = ;
} else if (tab === 'search') {
body = ;
} else if (tab === 'chat' && chatView === 'list') {
body = ;
} else if (tab === 'chat' && chatView === 'thread') {
body = ;
} else if (tab === 'activities') {
body = ;
} else if (tab === 'notification') {
body = ;
} else {
body = ;
}
const messageBadge = conversations.reduce((sum, c) => sum + (c.unread || 0), 0);
return (
{body}
{screen === 'app' && showTabBar &&
}
{screen === 'app' && showTabBar &&
}
{screen === 'app' && showDetail && (
shareProperty(selectedProperty.id, selectedProperty.name)} onOpenCalculator={openCalculator} onOpenBooking={() => openBookingCheckout(selectedProperty)} />
)}
{screen === 'app' && showProjectReviewDetail && (
)}
{screen === 'app' && !!calcConfig && (
setCalcConfig(null)} />
)}
{screen === 'app' && showInsurance && (
setShowInsurance(false)} />
)}
{screen === 'app' && showMyListings && (
)}
{screen === 'app' && showTenantPortal && (
setShowTenantPortal(false)} />
)}
{screen === 'app' && showMyListingDetail && !myListingDetailLoading && myListingDetail && (
)}
{screen === 'app' && showMyListingDetail && (myListingDetailLoading || !myListingDetail) && (
{myListingDetailLoading ? '…' : (lang === 'en' ? 'Could not load this listing.' : 'โหลดข้อมูลทรัพย์นี้ไม่สำเร็จ')}
)}
{screen === 'app' && showProfile && (
)}
{screen === 'app' && showEditProfile && (
)}
{screen === 'app' && showChangePassword && (
)}
{screen === 'app' && showPaymentMethods && (
)}
{screen === 'app' && showSocialAccounts && (
)}
{screen === 'app' && showContactUs && (
)}
{screen === 'app' && showAddCardResult && (
)}
{screen === 'app' && showBookingCheckout && bookingProperty && (
)}
{screen === 'app' && showMyBookings && (
)}
{screen === 'app' && showBookingResult && (
)}
{screen === 'app' && showSavedSearches && (
)}
{screen === 'app' && showListProperty && (
)}
{screen === 'app' && showGeneralInquire && (
)}
{screen === 'app' && showProvincePicker && (
)}
{screen === 'app' && showFilterPanel && (
)}
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );