Initial Commit

This commit is contained in:
2026-06-11 15:05:08 +08:00
commit ea8e41e688
21 changed files with 2317 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
<footer class="bg-primary text-gray-300 mt-16">
<div class="max-w-7xl mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="md:col-span-1">
<div class="flex items-center space-x-2 mb-4">
<svg class="w-7 h-7 text-warm" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3L2 12h3v8h6v-6h2v6h6v-8h3L12 3z"/>
</svg>
<span class="text-lg font-bold text-white">NexHome</span>
</div>
<p class="text-sm text-gray-400">Your trusted partner in finding the perfect home. Browse thousands of properties across the United States.</p>
</div>
<!-- Quick Links -->
<div>
<h3 class="text-white font-semibold mb-4">Quick Links</h3>
<ul class="space-y-2 text-sm">
<li><a href="/" class="hover:text-warm transition">Home</a></li>
<li><a href="/properties" class="hover:text-warm transition">Browse Properties</a></li>
<li><a href="/properties/new" class="hover:text-warm transition">List a Property</a></li>
</ul>
</div>
<!-- Property Types -->
<div>
<h3 class="text-white font-semibold mb-4">Property Types</h3>
<ul class="space-y-2 text-sm">
<li><a href="/properties?type=house" class="hover:text-warm transition">Houses</a></li>
<li><a href="/properties?type=condo" class="hover:text-warm transition">Condos</a></li>
<li><a href="/properties?type=townhouse" class="hover:text-warm transition">Townhouses</a></li>
<li><a href="/properties?type=land" class="hover:text-warm transition">Land</a></li>
</ul>
</div>
<!-- Contact -->
<div>
<h3 class="text-white font-semibold mb-4">Contact Us</h3>
<ul class="space-y-2 text-sm">
<li class="flex items-center space-x-2">
<svg class="w-4 h-4 text-warm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<span>info@nexhome.com</span>
</li>
<li class="flex items-center space-x-2">
<svg class="w-4 h-4 text-warm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/>
</svg>
<span>(800) 555-1154</span>
</li>
</ul>
</div>
</div>
<div class="border-t border-gray-600 mt-10 pt-6 text-center text-sm text-gray-500">
&copy; 2026 NexHome. All rights reserved. All prices in USD.
</div>
</div>
</footer>
+71
View File
@@ -0,0 +1,71 @@
<nav class="bg-primary shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center space-x-2">
<svg class="w-8 h-8 text-warm" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3L2 12h3v8h6v-6h2v6h6v-8h3L12 3z"/>
</svg>
<span class="text-xl font-bold text-white tracking-tight">NexHome</span>
</a>
<!-- Desktop nav -->
<div class="hidden md:flex items-center space-x-6">
<a href="/" class="text-gray-200 hover:text-white transition">Home</a>
<a href="/properties" class="text-gray-200 hover:text-white transition">Buy</a>
<a href="/properties?type=rent" class="text-gray-200 hover:text-white transition">Rent</a>
<a href="/properties/new" class="text-gray-200 hover:text-white transition">Sell</a>
{% if user %}
<a href="/dashboard" class="text-gray-200 hover:text-white transition">Dashboard</a>
<div class="flex items-center space-x-3 ml-2">
<span class="text-warm font-medium text-sm">{{ user.username }}</span>
<a href="/auth/logout"
class="bg-warm hover:bg-warm-dark text-primary font-semibold text-sm px-4 py-2 rounded-lg transition">
Logout
</a>
</div>
{% else %}
<a href="/auth/login"
class="text-gray-200 hover:text-white transition font-medium text-sm">
Login
</a>
<a href="/auth/register"
class="bg-warm hover:bg-warm-dark text-primary font-semibold text-sm px-4 py-2 rounded-lg transition">
Register
</a>
{% endif %}
</div>
<!-- Mobile menu button -->
<button id="mobile-menu-btn" class="md:hidden text-gray-200 hover:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
<!-- Mobile menu -->
<div id="mobile-menu" class="hidden md:hidden pb-4">
<div class="flex flex-col space-y-2">
<a href="/" class="text-gray-200 hover:text-white py-2">Home</a>
<a href="/properties" class="text-gray-200 hover:text-white py-2">Buy</a>
<a href="/properties?type=rent" class="text-gray-200 hover:text-white py-2">Rent</a>
<a href="/properties/new" class="text-gray-200 hover:text-white py-2">Sell</a>
{% if user %}
<a href="/dashboard" class="text-gray-200 hover:text-white py-2">Dashboard</a>
<a href="/auth/logout" class="text-warm hover:text-warm-dark py-2 font-medium">Logout ({{ user.username }})</a>
{% else %}
<a href="/auth/login" class="text-gray-200 hover:text-white py-2">Login</a>
<a href="/auth/register" class="text-warm hover:text-warm-dark py-2 font-medium">Register</a>
{% endif %}
</div>
</div>
</div>
</nav>
<script>
document.getElementById('mobile-menu-btn')?.addEventListener('click', () => {
document.getElementById('mobile-menu').classList.toggle('hidden');
});
</script>
+149
View File
@@ -0,0 +1,149 @@
{# Phone input component with country code selector, flags, dynamic placeholder, and auto-formatting.
Usage: {% include "components/phone_input.html" %}
Expects: name, value (optional), id (optional)
#}
<div class="phone-input-wrapper" id="phoneWrapper">
<label for="contact_phone_input" class="block text-sm font-medium text-gray-700 mb-1">Contact Phone</label>
<div class="flex rounded-lg border border-gray-300 overflow-hidden focus-within:ring-2 focus-within:ring-accent focus-within:border-transparent transition">
<!-- Country selector button -->
<button type="button" id="phoneCountryBtn"
class="flex items-center gap-1.5 px-3 bg-gray-50 border-r border-gray-300 hover:bg-gray-100 transition text-sm flex-shrink-0"
onclick="togglePhoneDropdown()">
<span id="phoneFlag" class="text-lg leading-none">🇺🇸</span>
<span id="phoneDialCode" class="text-gray-700 font-medium">+1</span>
<svg class="w-3 h-3 text-gray-500 ml-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- Hidden input for actual phone value (with dial code) -->
<input type="hidden" name="contact_phone" id="phoneHidden" value="{{ phone_value|default('') }}">
<!-- Visible phone display -->
<input type="tel" id="phoneDisplay"
class="flex-1 px-3 py-2.5 outline-none bg-white text-sm min-w-0"
placeholder="(555) 123-4567"
autocomplete="tel"
oninput="formatPhoneInput(this)"
onfocus="formatPhoneInput(this)">
</div>
<!-- Dropdown -->
<div id="phoneDropdown"
class="hidden absolute z-50 mt-1 w-72 bg-white border border-gray-200 rounded-xl shadow-xl max-h-64 overflow-y-auto">
</div>
</div>
<script>
(function() {
const COUNTRIES = [
{ code: 'US', flag: '🇺🇸', dial: '+1', name: 'United States', format: '(###) ###-####', digitCount: 10 },
{ code: 'CA', flag: '🇨🇦', dial: '+1', name: 'Canada', format: '(###) ###-####', digitCount: 10 },
{ code: 'GB', flag: '🇬🇧', dial: '+44', name: 'United Kingdom', format: '#### ######', digitCount: 10 },
{ code: 'AU', flag: '🇦🇺', dial: '+61', name: 'Australia', format: '#### ### ###', digitCount: 9 },
{ code: 'CN', flag: '🇨🇳', dial: '+86', name: 'China', format: '### #### ####', digitCount: 11 },
{ code: 'JP', flag: '🇯🇵', dial: '+81', name: 'Japan', format: '##-####-####', digitCount: 10 },
{ code: 'KR', flag: '🇰🇷', dial: '+82', name: 'South Korea', format: '##-####-####', digitCount: 10 },
{ code: 'DE', flag: '🇩🇪', dial: '+49', name: 'Germany', format: '### ########', digitCount: 11 },
{ code: 'FR', flag: '🇫🇷', dial: '+33', name: 'France', format: '# ## ## ## ##', digitCount: 9 },
{ code: 'IN', flag: '🇮🇳', dial: '+91', name: 'India', format: '##### #####', digitCount: 10 },
{ code: 'BR', flag: '🇧🇷', dial: '+55', name: 'Brazil', format: '## #####-####', digitCount: 11 },
{ code: 'MX', flag: '🇲🇽', dial: '+52', name: 'Mexico', format: '## #### ####', digitCount: 10 },
{ code: 'SG', flag: '🇸🇬', dial: '+65', name: 'Singapore', format: '#### ####', digitCount: 8 },
{ code: 'HK', flag: '🇭🇰', dial: '+852', name: 'Hong Kong(China)', format: '#### ####', digitCount: 8 },
{ code: 'TW', flag: '🇹🇼', dial: '+886', name: 'Taiwan(China)', format: '## ### ####', digitCount: 9 },
];
let selectedCountry = COUNTRIES[0]; // Default: US
let rawDigits = '';
// Parse initial value
const hiddenInput = document.getElementById('phoneHidden');
const displayInput = document.getElementById('phoneDisplay');
if (hiddenInput.value) {
for (const c of COUNTRIES) {
if (hiddenInput.value.startsWith(c.dial)) {
selectedCountry = c;
rawDigits = hiddenInput.value.replace(c.dial, '').replace(/\D/g, '');
break;
}
}
if (!rawDigits && hiddenInput.value) {
rawDigits = hiddenInput.value.replace(/\D/g, '');
}
}
function updateDisplay() {
document.getElementById('phoneFlag').textContent = selectedCountry.flag;
document.getElementById('phoneDialCode').textContent = selectedCountry.dial;
displayInput.placeholder = formatDigits('', selectedCountry);
displayInput.value = formatDigits(rawDigits, selectedCountry);
hiddenInput.value = rawDigits.length > 0 ? selectedCountry.dial + rawDigits : '';
}
function formatDigits(digits, country) {
if (!digits) return '';
let result = '';
let di = 0;
for (let i = 0; i < country.format.length && di < digits.length; i++) {
if (country.format[i] === '#') {
result += digits[di++];
} else {
result += country.format[i];
}
}
return result;
}
window.formatPhoneInput = function(input) {
const cursorPos = input.selectionStart;
const prevLength = input.value.length;
rawDigits = input.value.replace(/\D/g, '').substring(0, selectedCountry.digitCount);
updateDisplay();
// Restore approximate cursor position
const newLength = input.value.length;
const newPos = cursorPos + (newLength - prevLength);
input.setSelectionRange(newPos, newPos);
};
window.togglePhoneDropdown = function() {
const dd = document.getElementById('phoneDropdown');
dd.classList.toggle('hidden');
if (!dd.classList.contains('hidden')) {
renderDropdown();
}
};
window.selectCountry = function(code) {
selectedCountry = COUNTRIES.find(c => c.code === code);
rawDigits = rawDigits.substring(0, selectedCountry.digitCount);
updateDisplay();
document.getElementById('phoneDropdown').classList.add('hidden');
};
function renderDropdown() {
const dd = document.getElementById('phoneDropdown');
dd.innerHTML = COUNTRIES.map(c => `
<button type="button"
onclick="selectCountry('${c.code}')"
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-accent/5 transition text-left
${c.code === selectedCountry.code ? 'bg-accent/10' : ''}">
<span class="text-xl">${c.flag}</span>
<span class="text-sm font-medium text-gray-700">${c.name}</span>
<span class="text-sm text-gray-500 ml-auto">${c.dial}</span>
</button>
`).join('');
}
// Close dropdown on outside click
document.addEventListener('click', function(e) {
const wrapper = document.getElementById('phoneWrapper');
if (!wrapper.contains(e.target)) {
document.getElementById('phoneDropdown').classList.add('hidden');
}
});
// Initial render
updateDisplay();
})();
</script>
+61
View File
@@ -0,0 +1,61 @@
{% macro property_card(prop) %}
<div class="bg-white rounded-xl shadow-md overflow-hidden card-hover">
<a href="/properties/{{ prop.id }}" class="block">
<div class="relative h-52 bg-gray-200">
{% if prop.primary_image %}
<img src="/static/{{ prop.primary_image.image_path }}"
alt="{{ prop.title }}"
class="w-full h-full object-cover">
{% else %}
<div class="flex items-center justify-center h-full text-gray-400">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1h-2z"/>
</svg>
</div>
{% endif %}
{% if prop.is_featured %}
<span class="absolute top-3 left-3 bg-warm text-primary text-xs font-bold px-2.5 py-1 rounded-full">
Featured
</span>
{% endif %}
{% if prop.status == 'sold' %}
<span class="absolute top-3 right-3 bg-red-600 text-white text-xs font-bold px-2.5 py-1 rounded-full">
Sold
</span>
{% elif prop.status == 'pending' %}
<span class="absolute top-3 right-3 bg-yellow-500 text-white text-xs font-bold px-2.5 py-1 rounded-full">
Pending
</span>
{% endif %}
</div>
</a>
<div class="p-5">
<div class="flex items-center justify-between mb-2">
<span class="text-2xl font-bold text-primary">${{ "{:,}".format(prop.price) }}</span>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-1 truncate">
<a href="/properties/{{ prop.id }}" class="hover:text-accent transition">{{ prop.title }}</a>
</h3>
<p class="text-sm text-gray-500 mb-3 flex items-center">
<svg class="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
{{ prop.city }}, {{ prop.state }}
</p>
<div class="flex items-center justify-between text-sm text-gray-600 border-t pt-3 whitespace-nowrap">
<span>{{ prop.bedrooms }} bd</span>
<span>{{ prop.bathrooms }} ba</span>
<span>{{ "{:,}".format(prop.area_sqft) }} sqft</span>
</div>
</div>
</div>
{% endmacro %}