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
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Login - NexHome{% endblock %}
{% block content %}
<div class="min-h-[80vh] flex items-center justify-center py-12 px-4">
<div class="max-w-md w-full">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary">Welcome Back</h1>
<p class="text-gray-500 mt-2">Sign in to your NexHome account</p>
</div>
{% if error %}
<div class="bg-red-100 border border-red-200 text-red-800 rounded-lg p-4 mb-6 text-sm">
{{ error }}
</div>
{% endif %}
{% if request.query_params.get('registered') %}
<div class="bg-green-100 border border-green-200 text-green-800 rounded-lg p-4 mb-6 text-sm">
Registration successful! Please sign in.
</div>
{% endif %}
<div class="bg-white rounded-2xl shadow-lg p-8">
<form method="POST" action="/auth/login" class="space-y-5">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input type="text" id="username" name="username" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="Enter your username">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" id="password" name="password" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="Enter your password">
</div>
<button type="submit"
class="w-full bg-accent hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition">
Sign In
</button>
</form>
<div class="mt-6 text-center text-sm text-gray-500">
Don't have an account?
<a href="/auth/register" class="text-accent hover:text-blue-700 font-medium">Create one here</a>
</div>
</div>
</div>
</div>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Register - NexHome{% endblock %}
{% block content %}
<div class="min-h-[80vh] flex items-center justify-center py-12 px-4">
<div class="max-w-md w-full">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary">Create Account</h1>
<p class="text-gray-500 mt-2">Join NexHome and find your dream property</p>
</div>
{% if errors %}
<div class="bg-red-100 border border-red-200 text-red-800 rounded-lg p-4 mb-6 text-sm">
<ul class="list-disc list-inside space-y-1">
{% for e in errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="bg-white rounded-2xl shadow-lg p-8">
<form method="POST" action="/auth/register" class="space-y-5">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username *</label>
<input type="text" id="username" name="username" required
value="{{ username|default('') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="Choose a username">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" id="email" name="email"
value="{{ email|default('') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="your@email.com">
</div>
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
<input type="text" id="full_name" name="full_name"
value="{{ full_name|default('') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="John Smith">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password *</label>
<input type="password" id="password" name="password" required minlength="6"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="At least 6 characters">
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">Confirm Password *</label>
<input type="password" id="confirm_password" name="confirm_password" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition"
placeholder="Re-enter your password">
</div>
<button type="submit"
class="w-full bg-accent hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition">
Create Account
</button>
</form>
<div class="mt-6 text-center text-sm text-gray-500">
Already have an account?
<a href="/auth/login" class="text-accent hover:text-blue-700 font-medium">Sign in</a>
</div>
</div>
</div>
</div>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}NexHome{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#1e3a5f',
'primary-light': '#2d5a8e',
accent: '#2563eb',
warm: '#f59e0b',
'warm-dark': '#d97706',
}
}
}
}
</script>
<style>
.hero-bg {
background: linear-gradient(135deg, #1e3a5f 0%, #2d5a8e 50%, #1e3a5f 100%);
}
.card-hover {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0,0,0,0.15);
}
</style>
{% block head %}{% endblock %}
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
{% include "components/navbar.html" %}
{% if flash %}
<div class="max-w-7xl mx-auto px-4 mt-4 w-full">
<div class="rounded-lg p-4 text-sm font-medium
{% if flash_type == 'error' %}bg-red-100 text-red-800 border border-red-200
{% else %}bg-green-100 text-green-800 border border-green-200{% endif %}">
{{ flash }}
</div>
</div>
{% endif %}
<main class="flex-1">
{% block content %}{% endblock %}
</main>
{% include "components/footer.html" %}
</body>
</html>
+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 %}
+140
View File
@@ -0,0 +1,140 @@
{% extends "base.html" %}
{% block title %}Dashboard - NexHome{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-10">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold text-primary">Dashboard</h1>
<p class="text-gray-500 mt-1">Welcome back, {{ user.username }}!</p>
</div>
<a href="/properties/new"
class="bg-accent hover:bg-blue-700 text-white font-semibold px-6 py-2.5 rounded-lg transition text-sm">
+ New Listing
</a>
</div>
<!-- Tabs -->
<div class="flex gap-1 bg-gray-100 p-1 rounded-lg w-fit mb-8">
<button id="tab-listings" onclick="showTab('listings')"
class="tab-btn px-6 py-2 rounded-md text-sm font-medium bg-white shadow text-primary transition">
My Listings ({{ my_properties|length }})
</button>
<button id="tab-favorites" onclick="showTab('favorites')"
class="tab-btn px-6 py-2 rounded-md text-sm font-medium text-gray-500 hover:text-gray-700 transition">
My Favorites ({{ favorites|length }})
</button>
</div>
<!-- My Listings -->
<div id="panel-listings">
{% if my_properties %}
<div class="bg-white rounded-xl shadow-md overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-left">
<tr>
<th class="px-6 py-3 font-medium">Property</th>
<th class="px-6 py-3 font-medium">Price</th>
<th class="px-6 py-3 font-medium">Status</th>
<th class="px-6 py-3 font-medium">Created</th>
<th class="px-6 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y">
{% for prop in my_properties %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4">
<div class="flex items-center space-x-3">
{% if prop.primary_image %}
<img src="/static/{{ prop.primary_image.image_path }}" alt=""
class="w-14 h-10 rounded object-cover">
{% else %}
<div class="w-14 h-10 bg-gray-200 rounded flex items-center justify-center text-gray-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3"/>
</svg>
</div>
{% endif %}
<div>
<a href="/properties/{{ prop.id }}" class="font-medium text-gray-800 hover:text-accent transition">
{{ prop.title }}
</a>
<div class="text-xs text-gray-500">{{ prop.city }}, {{ prop.state }}</div>
</div>
</div>
</td>
<td class="px-6 py-4 font-semibold text-primary">${{ "{:,}".format(prop.price) }}</td>
<td class="px-6 py-4">
<span class="px-2.5 py-1 rounded-full text-xs font-medium
{% if prop.status == 'active' %}bg-green-100 text-green-800
{% elif prop.status == 'pending' %}bg-yellow-100 text-yellow-800
{% else %}bg-red-100 text-red-800{% endif %}">
{{ prop.status|capitalize }}
</span>
</td>
<td class="px-6 py-4 text-gray-500">{{ prop.created_at.strftime("%b %d, %Y") }}</td>
<td class="px-6 py-4 text-right space-x-2">
<a href="/properties/{{ prop.id }}/edit" class="text-accent hover:text-blue-700 transition font-medium">Edit</a>
<form method="POST" action="/properties/{{ prop.id }}/delete" class="inline"
onsubmit="return confirm('Delete this listing?')">
<button type="submit" class="text-red-500 hover:text-red-700 transition font-medium">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-16 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" 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>
<p class="text-lg mb-4">You haven't listed any properties yet.</p>
<a href="/properties/new" class="bg-accent hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium transition inline-block">
Create Your First Listing
</a>
</div>
{% endif %}
</div>
<!-- My Favorites -->
<div id="panel-favorites" class="hidden">
{% if favorites %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% from "components/property_card.html" import property_card %}
{% for fav_prop in favorites %}
{{ property_card(fav_prop) }}
{% endfor %}
</div>
{% else %}
<div class="text-center py-16 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
<p class="text-lg mb-4">No favorites yet.</p>
<a href="/properties" class="bg-accent hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium transition inline-block">
Browse Properties
</a>
</div>
{% endif %}
</div>
</div>
<script>
function showTab(tab) {
document.getElementById('panel-listings').classList.toggle('hidden', tab !== 'listings');
document.getElementById('panel-favorites').classList.toggle('hidden', tab !== 'favorites');
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('bg-white', 'shadow', 'text-primary');
btn.classList.add('text-gray-500');
});
const active = document.getElementById('tab-' + tab);
active.classList.add('bg-white', 'shadow', 'text-primary');
active.classList.remove('text-gray-500');
}
</script>
{% endblock %}
+99
View File
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}NexHome - Find Your Dream Home{% endblock %}
{% block content %}
<!-- Hero -->
<section class="hero-bg text-white py-20 md:py-32">
<div class="max-w-7xl mx-auto px-4 text-center">
<h1 class="text-4xl md:text-6xl font-extrabold mb-4 tracking-tight">
Find Your <span class="text-warm">Dream Home</span>
</h1>
<p class="text-xl text-gray-300 mb-10 max-w-2xl mx-auto">
Browse thousands of homes for sale across the United States. Your perfect property is just a search away.
</p>
<!-- Search bar -->
<form action="/properties" method="GET" class="max-w-3xl mx-auto">
<div class="flex flex-col sm:flex-row gap-3 bg-white/10 backdrop-blur p-4 rounded-2xl">
<input type="text" name="search" placeholder="Search by city, state, or zip code..."
class="flex-1 px-5 py-3 rounded-xl text-gray-800 focus:ring-2 focus:ring-warm outline-none">
<button type="submit"
class="bg-warm hover:bg-warm-dark text-primary font-bold px-8 py-3 rounded-xl transition">
Search
</button>
</div>
</form>
</div>
</section>
<!-- Featured Properties -->
<section class="max-w-7xl mx-auto px-4 py-16">
<div class="flex items-center justify-between mb-8">
<div>
<h2 class="text-3xl font-bold text-primary">Featured Properties</h2>
<p class="text-gray-500 mt-1">Hand-picked properties for you</p>
</div>
<a href="/properties" class="text-accent hover:text-blue-700 font-medium transition">
View All &rarr;
</a>
</div>
{% if properties %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% from "components/property_card.html" import property_card %}
{% for prop in properties %}
{{ property_card(prop) }}
{% endfor %}
</div>
{% else %}
<div class="text-center py-16 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" 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>
<p class="text-lg">No featured properties yet. Be the first to list!</p>
<a href="/properties/new" class="inline-block mt-4 bg-accent hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium transition">
List a Property
</a>
</div>
{% endif %}
</section>
<!-- Stats -->
<section class="bg-white py-16">
<div class="max-w-7xl mx-auto px-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
<div>
<div class="text-4xl font-extrabold text-accent mb-2">500+</div>
<div class="text-gray-500 font-medium">Properties Listed</div>
</div>
<div>
<div class="text-4xl font-extrabold text-accent mb-2">200+</div>
<div class="text-gray-500 font-medium">Happy Clients</div>
</div>
<div>
<div class="text-4xl font-extrabold text-accent mb-2">50+</div>
<div class="text-gray-500 font-medium">Cities Covered</div>
</div>
<div>
<div class="text-4xl font-extrabold text-accent mb-2">98%</div>
<div class="text-gray-500 font-medium">Satisfaction Rate</div>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="max-w-7xl mx-auto px-4 py-16">
<div class="bg-primary rounded-2xl p-10 md:p-16 text-center text-white">
<h2 class="text-3xl md:text-4xl font-bold mb-4">Ready to Sell Your Property?</h2>
<p class="text-gray-300 mb-8 max-w-xl mx-auto">
List your property on NexHome and reach thousands of potential buyers. It's fast, easy, and free.
</p>
<a href="/properties/new"
class="inline-block bg-warm hover:bg-warm-dark text-primary font-bold px-10 py-4 rounded-xl text-lg transition">
List Your Property
</a>
</div>
</section>
{% endblock %}
+173
View File
@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}List a Property - NexHome{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-4 py-10">
<h1 class="text-3xl font-bold text-primary mb-2">List a New Property</h1>
<p class="text-gray-500 mb-8">Fill in the details below to create your listing.</p>
{% if error %}
<div class="bg-red-100 border border-red-200 text-red-800 rounded-lg p-4 mb-6 text-sm">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/properties/new" enctype="multipart/form-data" class="bg-white rounded-2xl shadow-lg p-8 space-y-6">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Title *</label>
<input type="text" name="title" required
value="{{ values.title|default('') }}"
placeholder="e.g. Beautiful Family Home in Austin"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description *</label>
<textarea name="description" required rows="5"
placeholder="Describe your property..."
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">{{ values.description|default('') }}</textarea>
</div>
<!-- Price & Type -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Price (USD) *</label>
<div class="relative">
<span class="absolute left-3 top-3 text-gray-500 font-medium">$</span>
<input type="number" name="price" required min="0"
value="{{ values.price|default('') }}"
placeholder="450,000"
class="w-full pl-8 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Type *</label>
<select name="property_type" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
<option value="">Select type</option>
<option value="house" {% if values.property_type == 'house' %}selected{% endif %}>House</option>
<option value="condo" {% if values.property_type == 'condo' %}selected{% endif %}>Condo</option>
<option value="townhouse" {% if values.property_type == 'townhouse' %}selected{% endif %}>Townhouse</option>
<option value="land" {% if values.property_type == 'land' %}selected{% endif %}>Land</option>
</select>
</div>
</div>
<!-- Beds / Baths / Sqft -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bedrooms *</label>
<input type="number" name="bedrooms" required min="0" max="20"
value="{{ values.bedrooms|default('0') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bathrooms *</label>
<input type="number" name="bathrooms" required min="0" max="20" step="0.5"
value="{{ values.bathrooms|default('0') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Area (sqft) *</label>
<input type="number" name="area_sqft" required min="0"
value="{{ values.area_sqft|default('') }}"
placeholder="2000"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<!-- Address -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Street Address *</label>
<input type="text" name="address" required
value="{{ values.address|default('') }}"
placeholder="123 Main Street"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<!-- City / State / Zip -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">City *</label>
<input type="text" name="city" required
value="{{ values.city|default('') }}"
placeholder="Austin"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">State *</label>
<input type="text" name="state" required maxlength="2"
value="{{ values.state|default('') }}"
placeholder="TX"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code *</label>
<input type="text" name="zip_code" required maxlength="10"
value="{{ values.zip_code|default('') }}"
placeholder="78701"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<!-- Year Built -->
<div class="sm:w-1/2">
<label class="block text-sm font-medium text-gray-700 mb-1">Year Built</label>
<input type="number" name="year_built" min="1800" max="2030"
value="{{ values.year_built|default('') }}"
placeholder="e.g. 2005"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<!-- Contact Info -->
<div class="border-t pt-6">
<h3 class="text-lg font-semibold text-primary mb-4">Contact Information</h3>
<p class="text-sm text-gray-500 mb-4">Provide at least one contact method (email or phone).</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Email</label>
<input type="email" name="contact_email"
value="{{ values.contact_email|default('') }}"
placeholder="seller@example.com"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div class="relative">
{% set phone_value = values.contact_phone|default("") %}
{% include "components/phone_input.html" %}
</div>
</div>
</div>
<!-- Image Upload -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Image</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-accent transition">
<svg class="w-10 h-10 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm text-gray-500 mb-2">Click or drag to upload an image</p>
<input type="file" name="image" accept="image/*"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-accent file:text-white hover:file:bg-blue-700 transition">
<p class="text-xs text-gray-400 mt-2">JPG, PNG or WebP. Max 5MB.</p>
</div>
</div>
<!-- Submit -->
<div class="flex gap-4 pt-4">
<button type="submit"
class="flex-1 bg-accent hover:bg-blue-700 text-white font-semibold py-3.5 rounded-lg transition">
Publish Listing
</button>
<a href="/"
class="px-8 py-3.5 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition">
Cancel
</a>
</div>
</form>
</div>
{% endblock %}
+258
View File
@@ -0,0 +1,258 @@
{% extends "base.html" %}
{% block title %}{{ prop.title }} - NexHome{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-6">
<a href="/" class="hover:text-accent transition">Home</a>
<span class="mx-2">/</span>
<a href="/properties" class="hover:text-accent transition">Properties</a>
<span class="mx-2">/</span>
<a href="/properties?city={{ prop.city }}" class="hover:text-accent transition">{{ prop.city }}</a>
<span class="mx-2">/</span>
<span class="text-gray-800">{{ prop.title }}</span>
</nav>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Image -->
<div class="relative rounded-2xl overflow-hidden bg-gray-200 h-96">
{% if primary_image %}
<img src="/static/{{ 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-20 h-20" 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-4 left-4 bg-warm text-primary text-sm font-bold px-3 py-1 rounded-full">
Featured
</span>
{% endif %}
{% if prop.status == 'sold' %}
<span class="absolute top-4 right-4 bg-red-600 text-white text-sm font-bold px-3 py-1 rounded-full">
Sold
</span>
{% elif prop.status == 'pending' %}
<span class="absolute top-4 right-4 bg-yellow-500 text-white text-sm font-bold px-3 py-1 rounded-full">
Pending
</span>
{% endif %}
<!-- Floating favorite heart (top-right) -->
{% if user %}
<form method="POST" action="/properties/{{ prop.id }}/favorite" class="absolute top-4 right-4 {% if prop.status == 'sold' or prop.status == 'pending' %}top-14{% endif %}">
<button type="submit"
class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg transition
{% if is_favorited %}
bg-red-500 text-white hover:bg-red-600
{% else %}
bg-white/90 text-gray-600 hover:bg-white hover:text-red-500
{% endif %}">
<svg class="w-6 h-6" fill="{% if is_favorited %}currentColor{% else %}none{% endif %}" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</button>
</form>
{% endif %}
</div>
<!-- Thumbnail gallery -->
{% if images|length > 1 %}
<div class="flex gap-2 overflow-x-auto pb-2">
{% for img in images %}
<img src="/static/{{ img.image_path }}"
alt="Property image {{ loop.index }}"
class="h-20 w-28 object-cover rounded-lg flex-shrink-0 border-2 {% if img.is_primary %}border-accent{% else %}border-transparent{% endif %}">
{% endfor %}
</div>
{% endif %}
<!-- Title & Price -->
<div>
<div class="flex items-center justify-between flex-wrap gap-4">
<h1 class="text-3xl font-bold text-primary">{{ prop.title }}</h1>
<span class="text-3xl font-extrabold text-accent">${{ "{:,}".format(prop.price) }}</span>
</div>
<p class="flex items-center text-gray-500 mt-2">
<svg class="w-5 h-5 mr-1.5" 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.address }}, {{ prop.city }}, {{ prop.state }} {{ prop.zip_code }}
</p>
</div>
<!-- Key Stats -->
<div class="grid grid-cols-3 gap-4 bg-white rounded-xl p-6 shadow-sm">
<div class="text-center">
<div class="text-2xl font-bold text-primary">{{ prop.bedrooms }}</div>
<div class="text-sm text-gray-500">Bedrooms</div>
</div>
<div class="text-center border-x">
<div class="text-2xl font-bold text-primary">{{ prop.bathrooms }}</div>
<div class="text-sm text-gray-500">Bathrooms</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-primary">{{ "{:,}".format(prop.area_sqft) }}</div>
<div class="text-sm text-gray-500">Sq Ft</div>
</div>
</div>
<!-- Description -->
<div class="bg-white rounded-xl p-6 shadow-sm">
<h2 class="text-xl font-bold text-primary mb-4">Description</h2>
<p class="text-gray-600 leading-relaxed whitespace-pre-line">{{ prop.description }}</p>
</div>
<!-- Details -->
<div class="bg-white rounded-xl p-6 shadow-sm">
<h2 class="text-xl font-bold text-primary mb-4">Property Details</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="flex justify-between py-2 border-b">
<span class="text-gray-500">Property Type</span>
<span class="font-medium text-gray-800 capitalize">{{ prop.property_type }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-gray-500">Year Built</span>
<span class="font-medium text-gray-800">{{ prop.year_built or 'N/A' }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-gray-500">Status</span>
<span class="font-medium text-gray-800 capitalize">{{ prop.status }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-gray-500">Listed</span>
<span class="font-medium text-gray-800">{{ prop.created_at.strftime("%B %d, %Y") }}</span>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Contact Card -->
<div class="bg-white rounded-xl p-6 shadow-sm border-2 border-accent/20">
<h3 class="font-bold text-primary mb-4 text-lg">Contact Seller</h3>
<!-- Favorite button - large and prominent -->
{% if user %}
<form method="POST" action="/properties/{{ prop.id }}/favorite" class="mb-4">
<button type="submit"
class="w-full py-3.5 rounded-xl font-semibold transition text-base flex items-center justify-center gap-2
{% if is_favorited %}
bg-red-50 text-red-600 border-2 border-red-300 hover:bg-red-100
{% else %}
bg-gray-50 text-gray-700 border-2 border-gray-200 hover:border-red-300 hover:text-red-500 hover:bg-red-50
{% endif %}">
<svg class="w-6 h-6" fill="{% if is_favorited %}currentColor{% else %}none{% endif %}" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
{% if is_favorited %}Saved to Favorites{% else %}Add to Favorites{% endif %}
</button>
</form>
{% else %}
<a href="/auth/login"
class="block w-full text-center py-3.5 rounded-xl font-semibold transition text-base bg-accent hover:bg-blue-700 text-white mb-4">
Login to Save
</a>
{% endif %}
<!-- Contact info -->
<div class="space-y-3">
{% if prop.contact_email %}
<a href="mailto:{{ prop.contact_email }}"
class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-accent/5 transition group">
<div class="w-10 h-10 bg-accent/10 rounded-full flex items-center justify-center flex-shrink-0 group-hover:bg-accent/20 transition">
<svg class="w-5 h-5 text-accent" 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>
</div>
<div class="min-w-0">
<div class="text-xs text-gray-500">Email</div>
<div class="text-sm font-medium text-gray-800 truncate">{{ prop.contact_email }}</div>
</div>
</a>
{% endif %}
{% if prop.contact_phone %}
<a href="tel:{{ prop.contact_phone }}"
class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-accent/5 transition group">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0 group-hover:bg-green-200 transition">
<svg class="w-5 h-5 text-green-600" 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>
</div>
<div class="min-w-0">
<div class="text-xs text-gray-500">Phone</div>
<div class="text-sm font-medium text-gray-800">{{ prop.contact_phone }}</div>
</div>
</a>
{% endif %}
{% if not prop.contact_email and not prop.contact_phone %}
<!-- Fallback to owner info -->
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<div class="w-12 h-12 bg-primary rounded-full flex items-center justify-center text-white font-bold text-lg flex-shrink-0">
{{ prop.owner.username[0]|upper }}
</div>
<div class="min-w-0">
<div class="font-semibold text-gray-800">{{ prop.owner.username }}</div>
{% if prop.owner.full_name %}
<div class="text-sm text-gray-500">{{ prop.owner.full_name }}</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% if user.id == prop.owner_id %}
<div class="mt-4 pt-4 border-t space-y-2">
<a href="/properties/{{ prop.id }}/edit"
class="block w-full text-center py-2.5 bg-accent hover:bg-blue-700 text-white rounded-lg font-medium transition text-sm">
Edit Listing
</a>
<form method="POST" action="/properties/{{ prop.id }}/delete"
onsubmit="return confirm('Are you sure you want to delete this listing?')">
<button type="submit"
class="w-full py-2.5 bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 rounded-lg font-medium transition text-sm">
Delete Listing
</button>
</form>
</div>
{% endif %}
</div>
<!-- Listed By -->
<div class="bg-white rounded-xl p-6 shadow-sm">
<h3 class="font-bold text-primary mb-4">Listed By</h3>
<div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-primary rounded-full flex items-center justify-center text-white font-bold text-lg">
{{ prop.owner.username[0]|upper }}
</div>
<div>
<div class="font-semibold text-gray-800">{{ prop.owner.username }}</div>
{% if prop.owner.full_name %}
<div class="text-sm text-gray-500">{{ prop.owner.full_name }}</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+176
View File
@@ -0,0 +1,176 @@
{% extends "base.html" %}
{% block title %}Edit Listing - NexHome{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-4 py-10">
<h1 class="text-3xl font-bold text-primary mb-2">Edit Property</h1>
<p class="text-gray-500 mb-8">Update the details of your listing.</p>
{% if error %}
<div class="bg-red-100 border border-red-200 text-red-800 rounded-lg p-4 mb-6 text-sm">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/properties/{{ prop.id }}/edit" enctype="multipart/form-data" class="bg-white rounded-2xl shadow-lg p-8 space-y-6">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Title *</label>
<input type="text" name="title" required
value="{{ prop.title }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description *</label>
<textarea name="description" required rows="5"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">{{ prop.description }}</textarea>
</div>
<!-- Price & Type -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Price (USD) *</label>
<div class="relative">
<span class="absolute left-3 top-3 text-gray-500 font-medium">$</span>
<input type="number" name="price" required min="0"
value="{{ prop.price }}"
class="w-full pl-8 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Type *</label>
<select name="property_type" required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
<option value="house" {% if prop.property_type == 'house' %}selected{% endif %}>House</option>
<option value="condo" {% if prop.property_type == 'condo' %}selected{% endif %}>Condo</option>
<option value="townhouse" {% if prop.property_type == 'townhouse' %}selected{% endif %}>Townhouse</option>
<option value="land" {% if prop.property_type == 'land' %}selected{% endif %}>Land</option>
</select>
</div>
</div>
<!-- Beds / Baths / Sqft -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bedrooms *</label>
<input type="number" name="bedrooms" required min="0" max="20"
value="{{ prop.bedrooms }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bathrooms *</label>
<input type="number" name="bathrooms" required min="0" max="20" step="0.5"
value="{{ prop.bathrooms }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Area (sqft) *</label>
<input type="number" name="area_sqft" required min="0"
value="{{ prop.area_sqft }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<!-- Address -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Street Address *</label>
<input type="text" name="address" required
value="{{ prop.address }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<!-- City / State / Zip -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">City *</label>
<input type="text" name="city" required
value="{{ prop.city }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">State *</label>
<input type="text" name="state" required maxlength="2"
value="{{ prop.state }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code *</label>
<input type="text" name="zip_code" required maxlength="10"
value="{{ prop.zip_code }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
</div>
<!-- Year Built & Status -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Year Built</label>
<input type="number" name="year_built" min="1800" max="2030"
value="{{ prop.year_built or '' }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select name="status"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
<option value="active" {% if prop.status == 'active' %}selected{% endif %}>Active</option>
<option value="pending" {% if prop.status == 'pending' %}selected{% endif %}>Pending</option>
<option value="sold" {% if prop.status == 'sold' %}selected{% endif %}>Sold</option>
</select>
</div>
</div>
<!-- Contact Info -->
<div class="border-t pt-6">
<h3 class="text-lg font-semibold text-primary mb-4">Contact Information</h3>
<p class="text-sm text-gray-500 mb-4">Provide at least one contact method (email or phone).</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Email</label>
<input type="email" name="contact_email"
value="{{ prop.contact_email or '' }}"
placeholder="seller@example.com"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-accent outline-none transition">
</div>
<div class="relative">
{% set phone_value = prop.contact_phone or "" %}
{% include "components/phone_input.html" %}
</div>
</div>
</div>
<!-- Current Image -->
{% if primary_image %}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Current Image</label>
<img src="/static/{{ primary_image.image_path }}" alt="Current"
class="h-40 w-56 object-cover rounded-lg border">
</div>
{% endif %}
<!-- New Image Upload -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Replace Image</label>
<input type="file" name="image" accept="image/*"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-accent file:text-white hover:file:bg-blue-700 transition">
<p class="text-xs text-gray-400 mt-1">Leave empty to keep the current image.</p>
</div>
<!-- Submit -->
<div class="flex gap-4 pt-4">
<button type="submit"
class="flex-1 bg-accent hover:bg-blue-700 text-white font-semibold py-3.5 rounded-lg transition">
Save Changes
</button>
<a href="/properties/{{ prop.id }}"
class="px-8 py-3.5 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition">
Cancel
</a>
</div>
</form>
</div>
{% endblock %}
+136
View File
@@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block title %}Browse Properties - NexHome{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-10">
<h1 class="text-3xl font-bold text-primary mb-8">Browse Properties</h1>
<div class="flex flex-col lg:flex-row gap-8">
<!-- Filter Sidebar -->
<aside class="w-full lg:w-72 flex-shrink-0">
<form action="/properties" method="GET" class="bg-white rounded-2xl shadow-md p-6 space-y-5 sticky top-4">
<h3 class="font-semibold text-primary text-lg border-b pb-3">Filters</h3>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Search</label>
<input type="text" name="search" value="{{ search|default('') }}"
placeholder="City, state, zip..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Property Type</label>
<select name="type" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
<option value="">All Types</option>
<option value="house" {% if prop_type == 'house' %}selected{% endif %}>House</option>
<option value="condo" {% if prop_type == 'condo' %}selected{% endif %}>Condo</option>
<option value="townhouse" {% if prop_type == 'townhouse' %}selected{% endif %}>Townhouse</option>
<option value="land" {% if prop_type == 'land' %}selected{% endif %}>Land</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Price Range (USD)</label>
<div class="flex gap-2">
<input type="number" name="min_price" value="{{ min_price|default('') }}"
placeholder="Min" min="0"
class="w-1/2 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
<input type="number" name="max_price" value="{{ max_price|default('') }}"
placeholder="Max" min="0"
class="w-1/2 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bedrooms</label>
<select name="bedrooms" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
<option value="">Any</option>
<option value="1" {% if bedrooms == '1' %}selected{% endif %}>1+</option>
<option value="2" {% if bedrooms == '2' %}selected{% endif %}>2+</option>
<option value="3" {% if bedrooms == '3' %}selected{% endif %}>3+</option>
<option value="4" {% if bedrooms == '4' %}selected{% endif %}>4+</option>
<option value="5" {% if bedrooms == '5' %}selected{% endif %}>5+</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
<input type="text" name="city" value="{{ city_filter|default('') }}"
placeholder="e.g. Austin"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">State</label>
<input type="text" name="state" value="{{ state_filter|default('') }}"
placeholder="e.g. TX"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-accent outline-none">
</div>
<button type="submit"
class="w-full bg-accent hover:bg-blue-700 text-white font-semibold py-2.5 rounded-lg transition text-sm">
Apply Filters
</button>
<a href="/properties" class="block text-center text-sm text-gray-500 hover:text-accent transition">
Clear all filters
</a>
</form>
</aside>
<!-- Property Grid -->
<div class="flex-1">
{% if properties %}
<div class="mb-4 text-sm text-gray-500">
Showing {{ properties|length }} propert{{ "y" if properties|length == 1 else "ies" }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{% from "components/property_card.html" import property_card %}
{% for prop in properties %}
{{ property_card(prop) }}
{% endfor %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="flex justify-center items-center gap-2 mt-10">
{% if page > 1 %}
<a href="?{{ query_string }}&page={{ page - 1 }}"
class="px-4 py-2 bg-white border border-gray-300 rounded-lg text-sm hover:bg-gray-50 transition">
&laquo; Previous
</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium">{{ p }}</span>
{% else %}
<a href="?{{ query_string }}&page={{ p }}"
class="px-4 py-2 bg-white border border-gray-300 rounded-lg text-sm hover:bg-gray-50 transition">
{{ p }}
</a>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?{{ query_string }}&page={{ page + 1 }}"
class="px-4 py-2 bg-white border border-gray-300 rounded-lg text-sm hover:bg-gray-50 transition">
Next &raquo;
</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="text-center py-20 text-gray-400">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<p class="text-lg mb-2">No properties found</p>
<p class="text-sm">Try adjusting your filters or <a href="/properties" class="text-accent hover:underline">clear all filters</a>.</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}