polish nav, planner UX, country pills, and dev magic link
- Nav: Zillow-style centered logo, solid blue Sign In button - Planner: center app at 72rem, center wizard steps/header/preview - Country pills: UK/USA labels, remove Other, show permits slider inline under country so the effect is transparent and adjustable - Reset button: inline confirm (red "Sure? Reset") instead of alert - Worker: print magic link to console when DEBUG=true for local dev Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,84 @@
|
||||
|
||||
/* ── Component classes ── */
|
||||
@layer components {
|
||||
/* ── Navigation (Zillow-style: links | logo | links) ── */
|
||||
.nav-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
}
|
||||
.nav-inner {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
}
|
||||
.nav-logo {
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
.nav-logo img {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav-links a {
|
||||
color: #475569;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.nav-links a:hover {
|
||||
color: #1D4ED8;
|
||||
}
|
||||
.nav-links--left { flex: 1; justify-content: flex-start; }
|
||||
.nav-links--right { flex: 1; justify-content: flex-end; }
|
||||
a.nav-auth-btn,
|
||||
button.nav-auth-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #1D4ED8;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 8px rgba(29,78,216,0.25);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
a.nav-auth-btn:hover,
|
||||
button.nav-auth-btn:hover {
|
||||
background: #1E40AF;
|
||||
color: #fff;
|
||||
}
|
||||
.nav-badge {
|
||||
@apply bg-electric/10 text-electric px-2 py-0.5 text-xs font-semibold rounded-full;
|
||||
}
|
||||
.nav-form {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.nav-links { display: none; }
|
||||
.nav-inner { justify-content: center; }
|
||||
}
|
||||
|
||||
/* Page container */
|
||||
.container-page {
|
||||
@apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
color: var(--txt);
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
@@ -349,6 +351,7 @@
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-reset:hover { color: var(--head); border-color: var(--border-2); }
|
||||
.btn-reset--confirm { color: #DC2626; border-color: #FCA5A5; background: #FEF2F2; }
|
||||
|
||||
/* ── Pill Select ── */
|
||||
.pill-group {
|
||||
@@ -715,6 +718,9 @@
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
gap: 1rem;
|
||||
max-width: 560px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.wizard-dots {
|
||||
display: flex;
|
||||
@@ -775,6 +781,8 @@
|
||||
.wizard-step {
|
||||
display: none;
|
||||
max-width: 560px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.wizard-step.active {
|
||||
display: block;
|
||||
@@ -802,6 +810,9 @@
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
margin-top: 1.5rem;
|
||||
max-width: 560px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 560px;
|
||||
}
|
||||
.wiz-preview__item {
|
||||
flex: 1;
|
||||
@@ -830,6 +841,8 @@
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
max-width: 560px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wiz-btn--back {
|
||||
|
||||
@@ -86,7 +86,7 @@ const COUNTRY_PRESETS = {
|
||||
NL: { permitsCompliance: 10000 },
|
||||
SE: { permitsCompliance: 8000 },
|
||||
UK: { permitsCompliance: 10000 },
|
||||
OTHER: { permitsCompliance: 12000 },
|
||||
US: { permitsCompliance: 15000 },
|
||||
};
|
||||
// Track which keys the user has manually adjusted
|
||||
const _userAdjusted = new Set();
|
||||
@@ -102,11 +102,11 @@ function bindPills(){
|
||||
});
|
||||
// Apply country presets when country changes
|
||||
if(k==='country'){
|
||||
const preset = COUNTRY_PRESETS[v] || COUNTRY_PRESETS.OTHER;
|
||||
const preset = COUNTRY_PRESETS[v] || COUNTRY_PRESETS.DE;
|
||||
for(const [pk,pv] of Object.entries(preset)){
|
||||
if(!_userAdjusted.has(pk)){ S[pk]=pv; }
|
||||
}
|
||||
rebuildCapexInputs(); bindSliders(); bindPills();
|
||||
buildCountryPill(); rebuildCapexInputs(); bindSliders(); bindPills();
|
||||
}
|
||||
// Rebuild inputs if lighting options depend on venue
|
||||
if(k==='glassType'||k==='lightingType'){
|
||||
@@ -257,8 +257,9 @@ function buildCountryPill(){
|
||||
$('#inp-country').innerHTML = pillSelect('country','Country',[
|
||||
{v:'DE',l:'Germany'},{v:'ES',l:'Spain'},{v:'IT',l:'Italy'},
|
||||
{v:'FR',l:'France'},{v:'NL',l:'Netherlands'},{v:'SE',l:'Sweden'},
|
||||
{v:'UK',l:'United Kingdom'},{v:'OTHER',l:'Other'},
|
||||
],'Affects regulatory cost defaults');
|
||||
{v:'UK',l:'UK'},{v:'US',l:'USA'},
|
||||
]) + slider('permitsCompliance','Permits & Compliance',0,50000,1000,fE,
|
||||
'Building permits, noise studies, change-of-use, fire safety, and regulatory compliance. Adjusts automatically when you pick a country — feel free to override.');
|
||||
}
|
||||
|
||||
function rebuildCapexInputs(){
|
||||
@@ -286,14 +287,12 @@ function rebuildCapexInputs(){
|
||||
h+=slider('floorPrep','Floor Preparation',0,100000,1000,fE,'Floor leveling, sealing, and preparation for court installation in an existing rented building.')+
|
||||
slider('hvacUpgrade','HVAC Upgrade',0,200000,1000,fE,'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.')+
|
||||
slider('lightingUpgrade','Lighting Upgrade',0,100000,1000,fE,'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.')+
|
||||
slider('fitout','Fit-Out & Reception',0,300000,1000,fE,'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.')+
|
||||
slider('permitsCompliance','Permits & Compliance',0,50000,1000,fE,'Building permits, change-of-use application, fire safety review, noise assessment');
|
||||
slider('fitout','Fit-Out & Reception',0,300000,1000,fE,'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.');
|
||||
} else if(!isIn){
|
||||
h+=slider('outdoorFoundation','Concrete (\u20AC/m\u00B2)',0,150,1,fE,'Concrete pad per m\u00B2 for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.')+
|
||||
slider('outdoorSiteWork','Site Work',0,60000,500,fE,'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.')+
|
||||
slider('outdoorLighting','Lighting per Court',0,20000,500,fE,'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.')+
|
||||
slider('outdoorFencing','Fencing',0,40000,500,fE,'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.')+
|
||||
slider('permitsCompliance','Permits & Compliance',0,50000,1000,fE,'Building permits, noise assessment, environmental compliance');
|
||||
slider('outdoorFencing','Fencing',0,40000,500,fE,'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.');
|
||||
if(isBuy) h+=slider('landPriceSqm','Land Price (\u20AC/m\u00B2)',0,500,5,fE,'Land purchase price per m\u00B2. Varies by location, zoning, and accessibility.');
|
||||
}
|
||||
h+=slider('workingCapital','Working Capital',0,200000,1000,fE,'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer \u2014 underfunding is a common startup failure.')+
|
||||
@@ -701,8 +700,25 @@ function loadScenario(id){
|
||||
});
|
||||
}
|
||||
|
||||
let _resetPending = false;
|
||||
let _resetTimer = null;
|
||||
function resetToDefaults(){
|
||||
if(!confirm('Reset all assumptions to defaults?')) return;
|
||||
const btn = document.getElementById('resetDefaultsBtn');
|
||||
if(!_resetPending){
|
||||
_resetPending = true;
|
||||
btn.textContent = 'Sure? Reset';
|
||||
btn.classList.add('btn-reset--confirm');
|
||||
_resetTimer = setTimeout(()=>{
|
||||
_resetPending = false;
|
||||
btn.textContent = 'Reset to Defaults';
|
||||
btn.classList.remove('btn-reset--confirm');
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
clearTimeout(_resetTimer);
|
||||
_resetPending = false;
|
||||
btn.textContent = 'Reset to Defaults';
|
||||
btn.classList.remove('btn-reset--confirm');
|
||||
Object.assign(S, JSON.parse(JSON.stringify(DEFAULTS)));
|
||||
_userAdjusted.clear();
|
||||
buildInputs();
|
||||
@@ -804,7 +820,7 @@ function renderWizNav(){
|
||||
|
||||
const COUNTRY_NAMES = {
|
||||
DE:'Germany',ES:'Spain',IT:'Italy',FR:'France',NL:'Netherlands',
|
||||
SE:'Sweden',UK:'United Kingdom',OTHER:'Other'
|
||||
SE:'Sweden',UK:'United Kingdom',US:'United States'
|
||||
};
|
||||
|
||||
function populateWizAutoFill(){
|
||||
|
||||
@@ -19,33 +19,36 @@
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="sticky top-0 z-50 bg-white/80 backdrop-blur border-b border-light-gray">
|
||||
<div class="container-page flex items-center justify-between py-3">
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="{{ url_for('dashboard.index') if user else url_for('public.landing') }}" class="flex items-center no-underline">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}" class="h-8 w-auto">
|
||||
</a>
|
||||
<div class="hidden md:flex items-center gap-4 text-sm">
|
||||
<!-- Navigation — Zillow-style: [demand links] [logo] [supply links + auth] -->
|
||||
<nav class="nav-bar">
|
||||
<div class="nav-inner">
|
||||
<!-- Left: demand / buy side -->
|
||||
<div class="nav-links nav-links--left">
|
||||
<a href="{{ url_for('planner.index') }}">Planner</a>
|
||||
<a href="{{ url_for('leads.quote_request') }}">Get Quotes</a>
|
||||
</div>
|
||||
|
||||
<!-- Center: logo -->
|
||||
<a href="{{ url_for('dashboard.index') if user else url_for('public.landing') }}" class="nav-logo">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}">
|
||||
</a>
|
||||
|
||||
<!-- Right: supply side + auth -->
|
||||
<div class="nav-links nav-links--right">
|
||||
<a href="{{ url_for('directory.index') }}">Directory</a>
|
||||
<span class="text-slate-300">|</span>
|
||||
<a href="{{ url_for('public.suppliers') }}">For Suppliers</a>
|
||||
<a href="{{ url_for('public.landing') }}#faq">Help</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
{% if user %}
|
||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
{% if session.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.index') }}"><span class="badge">Admin</span></a>
|
||||
<a href="{{ url_for('admin.index') }}" class="nav-badge">Admin</a>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="m-0">
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="nav-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm">Sign Out</button>
|
||||
<button type="submit" class="nav-auth-btn">Sign Out</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}" class="btn-outline btn-sm">Sign In</a>
|
||||
<a href="{{ url_for('auth.login') }}" class="nav-auth-btn">Sign In</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,6 +146,12 @@ async def handle_send_magic_link(payload: dict) -> None:
|
||||
"""Send magic link email."""
|
||||
link = f"{config.BASE_URL}/auth/verify?token={payload['token']}"
|
||||
|
||||
if config.DEBUG:
|
||||
print(f"\n{'='*60}")
|
||||
print(f" MAGIC LINK for {payload['email']}")
|
||||
print(f" {link}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
body = (
|
||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Sign in to {config.APP_NAME}</h2>'
|
||||
f"<p>Click the button below to sign in. This link expires in "
|
||||
|
||||
@@ -1033,7 +1033,7 @@ plausible_state = st.fixed_dictionaries({
|
||||
"budgetTarget": st.integers(0, 5000000),
|
||||
"glassType": st.sampled_from(["standard", "panoramic"]),
|
||||
"lightingType": st.sampled_from(["led_standard", "led_competition", "natural"]),
|
||||
"country": st.sampled_from(["DE", "ES", "IT", "FR", "NL", "SE", "UK", "OTHER"]),
|
||||
"country": st.sampled_from(["DE", "ES", "IT", "FR", "NL", "SE", "UK", "US"]),
|
||||
"permitsCompliance": st.integers(0, 50000),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user