126 changed files with 41885 additions and 240 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,68 @@ |
|||||||
|
from flask import Blueprint, render_template, session, redirect, url_for, flash |
||||||
|
|
||||||
|
portal_services_bp = Blueprint("portal_services", __name__) |
||||||
|
|
||||||
|
def _portal_user_is_logged_in() -> bool: |
||||||
|
return bool( |
||||||
|
session.get("portal_user_id") |
||||||
|
or session.get("client_user_id") |
||||||
|
or session.get("portal_client_id") |
||||||
|
or session.get("client_id") |
||||||
|
or session.get("user_id") |
||||||
|
) |
||||||
|
|
||||||
|
@portal_services_bp.route("/portal/services") |
||||||
|
def portal_services_home(): |
||||||
|
if not _portal_user_is_logged_in(): |
||||||
|
flash("Please sign in to access services.", "warning") |
||||||
|
return redirect(url_for("portal_login")) |
||||||
|
|
||||||
|
client = { |
||||||
|
"contact_name": session.get("portal_contact_name"), |
||||||
|
"company_name": session.get("portal_company_name"), |
||||||
|
"email": session.get("portal_email"), |
||||||
|
} |
||||||
|
|
||||||
|
client_name = ( |
||||||
|
client.get("contact_name") |
||||||
|
or client.get("company_name") |
||||||
|
or client.get("email") |
||||||
|
or "Client" |
||||||
|
) |
||||||
|
|
||||||
|
services = [ |
||||||
|
{ |
||||||
|
"key": "follow_me", |
||||||
|
"name": "Follow-me Tracker", |
||||||
|
"summary": "Create and manage your GPS tracking network. Free for up to 2 users.", |
||||||
|
"status": "beta", |
||||||
|
"enabled": True, |
||||||
|
"href": "https://follow-me.outsidethebox.top", |
||||||
|
"button_text": "Open Follow-me", |
||||||
|
}, |
||||||
|
{ |
||||||
|
"key": "video_render", |
||||||
|
"name": "Video Rendering / Streaming", |
||||||
|
"summary": "Submit video rendering, conversion, and hosted streaming jobs.", |
||||||
|
"status": "coming_soon", |
||||||
|
"enabled": False, |
||||||
|
"href": "#", |
||||||
|
"button_text": "Coming Soon", |
||||||
|
}, |
||||||
|
{ |
||||||
|
"key": "miner_rentals", |
||||||
|
"name": "Miner Rentals", |
||||||
|
"summary": "Rent available OTB hashpower by time or package.", |
||||||
|
"status": "coming_soon", |
||||||
|
"enabled": False, |
||||||
|
"href": "#", |
||||||
|
"button_text": "Coming Soon", |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
return render_template( |
||||||
|
"portal/services_here.html", |
||||||
|
client=client, |
||||||
|
client_name=client_name, |
||||||
|
services=services, |
||||||
|
) |
||||||
@ -0,0 +1,142 @@ |
|||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
|
||||||
|
<h1>Services Here</h1> |
||||||
|
<p>Launch available OTB services.</p> |
||||||
|
<p>Logged in as: {{ client_name }}</p> |
||||||
|
{% block title %}Services Here{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<section class="portal-hero"> |
||||||
|
<div class="portal-hero-copy"> |
||||||
|
<h1>Services Here</h1> |
||||||
|
<p class="portal-subtitle"> |
||||||
|
Launch available OTB services from one place. |
||||||
|
</p> |
||||||
|
<p class="portal-muted"> |
||||||
|
Logged in as: {{ client_name }} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-hero-actions"> |
||||||
|
<a class="btn btn-secondary" href="{{ url_for('client_portal_dashboard') }}">Back to Dashboard</a> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<section class="services-grid"> |
||||||
|
{% for service in services %} |
||||||
|
<article class="service-card status-{{ service.status }}"> |
||||||
|
<div class="service-card-header"> |
||||||
|
<div> |
||||||
|
<h2>{{ service.name }}</h2> |
||||||
|
<p>{{ service.summary }}</p> |
||||||
|
</div> |
||||||
|
<div class="service-badge-wrap"> |
||||||
|
{% if service.status == 'beta' %} |
||||||
|
<span class="service-badge service-badge-beta">Beta</span> |
||||||
|
{% elif service.status == 'coming_soon' %} |
||||||
|
<span class="service-badge service-badge-soon">Coming Soon</span> |
||||||
|
{% else %} |
||||||
|
<span class="service-badge">Available</span> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="service-card-actions"> |
||||||
|
{% if service.enabled %} |
||||||
|
<a class="btn btn-primary" href="{{ service.href }}">{{ service.button_text }}</a> |
||||||
|
{% else %} |
||||||
|
<button class="btn btn-disabled" type="button" disabled>{{ service.button_text }}</button> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</article> |
||||||
|
{% endfor %} |
||||||
|
</section> |
||||||
|
|
||||||
|
<style> |
||||||
|
.portal-hero { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: flex-start; |
||||||
|
gap: 20px; |
||||||
|
margin-bottom: 28px; |
||||||
|
flex-wrap: wrap; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-hero h1 { |
||||||
|
margin: 0 0 8px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-subtitle { |
||||||
|
margin: 0 0 8px 0; |
||||||
|
opacity: 0.95; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-muted { |
||||||
|
margin: 0; |
||||||
|
opacity: 0.75; |
||||||
|
font-size: 0.95rem; |
||||||
|
} |
||||||
|
|
||||||
|
.services-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); |
||||||
|
gap: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.service-card { |
||||||
|
border-radius: 18px; |
||||||
|
padding: 22px; |
||||||
|
border: 1px solid rgba(255,255,255,0.10); |
||||||
|
background: rgba(8, 18, 37, 0.72); |
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
|
||||||
|
.service-card-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
gap: 16px; |
||||||
|
align-items: flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.service-card h2 { |
||||||
|
margin: 0 0 10px 0; |
||||||
|
font-size: 1.3rem; |
||||||
|
} |
||||||
|
|
||||||
|
.service-card p { |
||||||
|
margin: 0; |
||||||
|
line-height: 1.5; |
||||||
|
opacity: 0.92; |
||||||
|
} |
||||||
|
|
||||||
|
.service-card-actions { |
||||||
|
margin-top: 22px; |
||||||
|
} |
||||||
|
|
||||||
|
.service-badge { |
||||||
|
display: inline-block; |
||||||
|
padding: 6px 10px; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.82rem; |
||||||
|
font-weight: 700; |
||||||
|
white-space: nowrap; |
||||||
|
background: rgba(255,255,255,0.10); |
||||||
|
} |
||||||
|
|
||||||
|
.service-badge-beta { |
||||||
|
background: rgba(73, 192, 255, 0.18); |
||||||
|
} |
||||||
|
|
||||||
|
.service-badge-soon { |
||||||
|
background: rgba(255, 196, 73, 0.18); |
||||||
|
} |
||||||
|
|
||||||
|
.btn-disabled { |
||||||
|
opacity: 0.65; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
</style> |
||||||
|
{% endblock %} |
||||||
|
</div> |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html> |
||||||
|
<head> |
||||||
|
<meta charset="utf-8"> |
||||||
|
<title>{{ page_title }}</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
</head> |
||||||
|
|
||||||
|
<body> |
||||||
|
|
||||||
|
<header> |
||||||
|
<h1>OTB Billing</h1> |
||||||
|
</header> |
||||||
|
|
||||||
|
<main> |
||||||
|
<p>{{ content }}</p> |
||||||
|
</main> |
||||||
|
|
||||||
|
</body> |
||||||
|
</html> |
||||||
|
{% include "footer.html" %} |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html> |
||||||
|
<head> |
||||||
|
<title>OTB Billing Dashboard</title> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
|
||||||
|
<div class="otb-page"> |
||||||
|
{% if app_settings.business_logo_url %} |
||||||
|
<div style="margin-bottom:15px;"> |
||||||
|
<img src="{{ app_settings.business_logo_url }}" style="height:60px;" alt="Logo"> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<h1 class="otb-section-title">{{ app_settings.business_name or 'OTB Billing' }} Dashboard</h1> |
||||||
|
|
||||||
|
{% if request.args.get('pkg_email') == '1' %} |
||||||
|
<div class="otb-alert otb-alert-success"> |
||||||
|
Accounting package emailed successfully. |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if request.args.get('pkg_email_failed') == '1' %} |
||||||
|
<div class="otb-alert otb-alert-error"> |
||||||
|
Accounting package email failed. Check SMTP settings or server log. |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<div class="otb-nav"> |
||||||
|
<a href="/clients">Clients</a> |
||||||
|
<a href="/services">Services</a> |
||||||
|
<a href="/invoices">Invoices</a> |
||||||
|
<a href="/payments">Payments</a> |
||||||
|
<a href="/subscriptions">Subscriptions</a> |
||||||
|
<a href="/reports/revenue">Revenue Report</a> |
||||||
|
<a href="/reports/aging">Aging Report</a> |
||||||
|
<a href="/reports/accounting-package.zip">Monthly Accounting Package</a> |
||||||
|
<a href="/settings">Settings / Config</a> |
||||||
|
<a href="/health">Health</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<form method="post" action="/reports/accounting-package/email" style="margin:0 0 16px 0;"> |
||||||
|
<button type="submit">Email Accounting Package</button> |
||||||
|
</form> |
||||||
|
|
||||||
|
<table class="otb-table otb-summary-table"> |
||||||
|
<tr> |
||||||
|
<th>Total Clients</th> |
||||||
|
<th>Active Services</th> |
||||||
|
<th>Outstanding Invoices</th> |
||||||
|
<th>Outstanding Balance (CAD)</th> |
||||||
|
<th>Revenue Received (CAD)</th> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<td>{{ total_clients }}</td> |
||||||
|
<td>{{ active_services }}</td> |
||||||
|
<td>{{ outstanding_invoices }}</td> |
||||||
|
<td>{{ outstanding_balance|money('CAD') }}</td> |
||||||
|
<td>{{ revenue_received|money('CAD') }}</td> |
||||||
|
</tr> |
||||||
|
</table> |
||||||
|
|
||||||
|
<p>Displayed times are shown in Eastern Time (Toronto).</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,376 @@ |
|||||||
|
<style> |
||||||
|
:root { |
||||||
|
--otb-bg: #f5f7fb; |
||||||
|
--otb-text: #1e293b; |
||||||
|
--otb-card: #ffffff; |
||||||
|
--otb-border: #cbd5e1; |
||||||
|
--otb-link: #1d4ed8; |
||||||
|
--otb-muted: #64748b; |
||||||
|
--otb-shadow: 0 2px 8px rgba(15,23,42,0.08); |
||||||
|
--otb-hover: #eef4ff; |
||||||
|
|
||||||
|
--otb-success-bg: #dcfce7; |
||||||
|
--otb-success-text: #166534; |
||||||
|
|
||||||
|
--otb-warning-bg: #fef3c7; |
||||||
|
--otb-warning-text: #92400e; |
||||||
|
|
||||||
|
--otb-danger-bg: #fee2e2; |
||||||
|
--otb-danger-text: #991b1b; |
||||||
|
|
||||||
|
--otb-info-bg: #dbeafe; |
||||||
|
--otb-info-text: #1d4ed8; |
||||||
|
|
||||||
|
--otb-neutral-bg: #e5e7eb; |
||||||
|
--otb-neutral-text: #374151; |
||||||
|
} |
||||||
|
|
||||||
|
body.otb-dark { |
||||||
|
--otb-bg: #0f172a; |
||||||
|
--otb-text: #e5e7eb; |
||||||
|
--otb-card: #111827; |
||||||
|
--otb-border: #334155; |
||||||
|
--otb-link: #93c5fd; |
||||||
|
--otb-muted: #94a3b8; |
||||||
|
--otb-shadow: 0 2px 10px rgba(0,0,0,0.35); |
||||||
|
--otb-hover: #172554; |
||||||
|
|
||||||
|
--otb-success-bg: #052e16; |
||||||
|
--otb-success-text: #86efac; |
||||||
|
|
||||||
|
--otb-warning-bg: #451a03; |
||||||
|
--otb-warning-text: #fcd34d; |
||||||
|
|
||||||
|
--otb-danger-bg: #450a0a; |
||||||
|
--otb-danger-text: #fca5a5; |
||||||
|
|
||||||
|
--otb-info-bg: #172554; |
||||||
|
--otb-info-text: #93c5fd; |
||||||
|
|
||||||
|
--otb-neutral-bg: #1f2937; |
||||||
|
--otb-neutral-text: #d1d5db; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
background: var(--otb-bg); |
||||||
|
color: var(--otb-text); |
||||||
|
font-family: Arial, Helvetica, sans-serif; |
||||||
|
margin: 0; |
||||||
|
padding: 24px; |
||||||
|
transition: background 0.2s ease, color 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
a { |
||||||
|
color: var(--otb-link); |
||||||
|
} |
||||||
|
|
||||||
|
img { |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
button, |
||||||
|
input, |
||||||
|
select, |
||||||
|
textarea { |
||||||
|
font: inherit; |
||||||
|
} |
||||||
|
|
||||||
|
button { |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-page { |
||||||
|
max-width: 1240px; |
||||||
|
margin: 0 auto; |
||||||
|
background: var(--otb-card); |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
box-shadow: var(--otb-shadow); |
||||||
|
padding: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table, |
||||||
|
table { |
||||||
|
border-collapse: collapse; |
||||||
|
width: 100%; |
||||||
|
margin-top: 14px; |
||||||
|
background: var(--otb-card); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table th, |
||||||
|
.otb-table td, |
||||||
|
table th, |
||||||
|
table td { |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
padding: 8px 10px; |
||||||
|
vertical-align: top; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table th, |
||||||
|
table th { |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table tbody tr:hover, |
||||||
|
table tbody tr:hover, |
||||||
|
table tr:hover td { |
||||||
|
background: var(--otb-hover); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-money { |
||||||
|
text-align: right; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-alert { |
||||||
|
padding: 10px; |
||||||
|
margin-bottom: 15px; |
||||||
|
max-width: 950px; |
||||||
|
border: 1px solid; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-alert-success { |
||||||
|
background: var(--otb-success-bg); |
||||||
|
border-color: var(--otb-success-text); |
||||||
|
color: var(--otb-success-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-alert-error { |
||||||
|
background: var(--otb-danger-bg); |
||||||
|
border-color: var(--otb-danger-text); |
||||||
|
color: var(--otb-danger-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-footer { |
||||||
|
margin: 18px auto 0 auto; |
||||||
|
max-width: 1240px; |
||||||
|
font-size: 12px; |
||||||
|
color: var(--otb-muted); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-nav { |
||||||
|
display: flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
gap: 10px; |
||||||
|
margin: 18px 0 20px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-nav a, |
||||||
|
.otb-nav button { |
||||||
|
display: inline-block; |
||||||
|
text-decoration: none; |
||||||
|
padding: 9px 14px; |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
background: var(--otb-card); |
||||||
|
color: var(--otb-text); |
||||||
|
border-radius: 10px; |
||||||
|
box-shadow: var(--otb-shadow); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-nav a:hover, |
||||||
|
.otb-nav button:hover { |
||||||
|
background: var(--otb-hover); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-section-title { |
||||||
|
margin-top: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status { |
||||||
|
display: inline-block; |
||||||
|
padding: 3px 10px; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 12px; |
||||||
|
font-weight: bold; |
||||||
|
letter-spacing: 0.02em; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-paid, |
||||||
|
.otb-status-active, |
||||||
|
.otb-status-confirmed, |
||||||
|
.otb-status-sent { |
||||||
|
background: var(--otb-success-bg); |
||||||
|
color: var(--otb-success-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-pending, |
||||||
|
.otb-status-partial, |
||||||
|
.otb-status-warning, |
||||||
|
.otb-status-paused { |
||||||
|
background: var(--otb-warning-bg); |
||||||
|
color: var(--otb-warning-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-overdue, |
||||||
|
.otb-status-cancelled, |
||||||
|
.otb-status-reversed, |
||||||
|
.otb-status-failed { |
||||||
|
background: var(--otb-danger-bg); |
||||||
|
color: var(--otb-danger-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-draft, |
||||||
|
.otb-status-current, |
||||||
|
.otb-status-info { |
||||||
|
background: var(--otb-info-bg); |
||||||
|
color: var(--otb-info-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-neutral { |
||||||
|
background: var(--otb-neutral-bg); |
||||||
|
color: var(--otb-neutral-text); |
||||||
|
} |
||||||
|
|
||||||
|
/* Toggle switch */ |
||||||
|
.otb-theme-toggle-wrap { |
||||||
|
position: fixed; |
||||||
|
top: 12px; |
||||||
|
right: 12px; |
||||||
|
z-index: 9999; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 8px; |
||||||
|
background: var(--otb-card); |
||||||
|
color: var(--otb-text); |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
box-shadow: var(--otb-shadow); |
||||||
|
border-radius: 999px; |
||||||
|
padding: 8px 12px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-theme-toggle-wrap .label { |
||||||
|
font-size: 12px; |
||||||
|
color: var(--otb-muted); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch { |
||||||
|
position: relative; |
||||||
|
display: inline-block; |
||||||
|
width: 46px; |
||||||
|
height: 24px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch input { |
||||||
|
opacity: 0; |
||||||
|
width: 0; |
||||||
|
height: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-slider { |
||||||
|
position: absolute; |
||||||
|
inset: 0; |
||||||
|
background: #cbd5e1; |
||||||
|
border-radius: 999px; |
||||||
|
transition: 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-slider:before { |
||||||
|
content: ""; |
||||||
|
position: absolute; |
||||||
|
height: 18px; |
||||||
|
width: 18px; |
||||||
|
left: 3px; |
||||||
|
bottom: 3px; |
||||||
|
background: white; |
||||||
|
border-radius: 50%; |
||||||
|
transition: 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch input:checked + .otb-slider { |
||||||
|
background: #2563eb; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch input:checked + .otb-slider:before { |
||||||
|
transform: translateX(22px); |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<div class="otb-theme-toggle-wrap"> |
||||||
|
<span class="label">Theme</span> |
||||||
|
<label class="otb-switch"> |
||||||
|
<input type="checkbox" id="otbThemeToggle"> |
||||||
|
<span class="otb-slider"></span> |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
|
||||||
|
<hr> |
||||||
|
<div class="otb-footer"> |
||||||
|
OTB Billing v{{ app_version }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function () { |
||||||
|
const themeKey = "otb_theme"; |
||||||
|
const toggle = document.getElementById("otbThemeToggle"); |
||||||
|
|
||||||
|
function applyTheme(theme) { |
||||||
|
if (theme === "dark") { |
||||||
|
document.body.classList.add("otb-dark"); |
||||||
|
if (toggle) toggle.checked = true; |
||||||
|
} else { |
||||||
|
document.body.classList.remove("otb-dark"); |
||||||
|
if (toggle) toggle.checked = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const savedTheme = localStorage.getItem(themeKey) || "light"; |
||||||
|
applyTheme(savedTheme); |
||||||
|
|
||||||
|
if (toggle) { |
||||||
|
toggle.addEventListener("change", function () { |
||||||
|
const nextTheme = toggle.checked ? "dark" : "light"; |
||||||
|
localStorage.setItem(themeKey, nextTheme); |
||||||
|
applyTheme(nextTheme); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function normalizeStatusText(text) { |
||||||
|
return (text || "") |
||||||
|
.trim() |
||||||
|
.toLowerCase() |
||||||
|
.replace(/\s+/g, "-"); |
||||||
|
} |
||||||
|
|
||||||
|
function statusClassFor(value) { |
||||||
|
const normalized = normalizeStatusText(value); |
||||||
|
|
||||||
|
if (["paid", "active", "confirmed", "sent"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-paid"; |
||||||
|
} |
||||||
|
if (["pending", "partial", "paused"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-pending"; |
||||||
|
} |
||||||
|
if (["overdue", "cancelled", "reversed", "failed"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-overdue"; |
||||||
|
} |
||||||
|
if (["draft", "current"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-draft"; |
||||||
|
} |
||||||
|
|
||||||
|
return ""; |
||||||
|
} |
||||||
|
|
||||||
|
function enhanceStatusCells() { |
||||||
|
const cells = document.querySelectorAll("td, span, div"); |
||||||
|
cells.forEach(function (el) { |
||||||
|
if (el.dataset.otbStatusDone === "1") return; |
||||||
|
if (el.children.length > 0) return; |
||||||
|
|
||||||
|
const text = (el.textContent || "").trim(); |
||||||
|
const klass = statusClassFor(text); |
||||||
|
|
||||||
|
if (!klass) return; |
||||||
|
|
||||||
|
if (el.tagName.toLowerCase() === "span") { |
||||||
|
el.className = (el.className ? el.className + " " : "") + klass; |
||||||
|
} else if (el.tagName.toLowerCase() === "td" || el.tagName.toLowerCase() === "div") { |
||||||
|
el.innerHTML = '<span class="' + klass + '">' + text + '</span>'; |
||||||
|
} |
||||||
|
|
||||||
|
el.dataset.otbStatusDone = "1"; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
enhanceStatusCells(); |
||||||
|
})(); |
||||||
|
</script> |
||||||
@ -0,0 +1,139 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>System Health - OTB Billing</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.page-wrap { |
||||||
|
padding: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.page-header h1 { |
||||||
|
margin-bottom: 0.35rem; |
||||||
|
} |
||||||
|
|
||||||
|
.page-header p { |
||||||
|
margin-top: 0; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
.health-links { |
||||||
|
margin: 1rem 0 1.25rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.health-links a { |
||||||
|
margin-right: 1rem; |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.health-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); |
||||||
|
gap: 1rem; |
||||||
|
margin-top: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.health-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem 1.1rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,0.16); |
||||||
|
} |
||||||
|
|
||||||
|
.health-card h3 { |
||||||
|
margin-top: 0; |
||||||
|
margin-bottom: 0.85rem; |
||||||
|
} |
||||||
|
|
||||||
|
.health-card p { |
||||||
|
margin: 0.45rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.status-good { |
||||||
|
color: #63d471; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.status-bad { |
||||||
|
color: #ff7b7b; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.monoish { |
||||||
|
word-break: break-word; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="page-wrap"> |
||||||
|
<div class="page-header"> |
||||||
|
<h1>System Health</h1> |
||||||
|
<p>Application and server status for OTB Billing.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="health-links"> |
||||||
|
<a href="/">Home</a> |
||||||
|
<a href="/health.json">Raw JSON</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="health-grid"> |
||||||
|
<div class="health-card"> |
||||||
|
<h3>Status</h3> |
||||||
|
{% if health.status == "ok" %} |
||||||
|
<p class="status-good">Healthy</p> |
||||||
|
{% else %} |
||||||
|
<p class="status-bad">Degraded</p> |
||||||
|
{% endif %} |
||||||
|
<p><strong>App:</strong> {{ health.app_name }}</p> |
||||||
|
<p><strong>Host:</strong> {{ health.hostname }}</p> |
||||||
|
<p class="monoish"><strong>Toronto Time:</strong> {{ health.server_time_toronto }}</p> |
||||||
|
<p class="monoish"><strong>UTC Time:</strong> {{ health.server_time_utc }}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="health-card"> |
||||||
|
<h3>Database</h3> |
||||||
|
{% if health.database.ok %} |
||||||
|
<p class="status-good">Connected</p> |
||||||
|
{% else %} |
||||||
|
<p class="status-bad">Connection Error</p> |
||||||
|
{% endif %} |
||||||
|
<p class="monoish"><strong>Error:</strong> {{ health.database.error or "None" }}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="health-card"> |
||||||
|
<h3>Uptime</h3> |
||||||
|
<p><strong>Application:</strong> {{ health.app_uptime_human }}</p> |
||||||
|
<p><strong>Server:</strong> {{ health.server_uptime_human }}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="health-card"> |
||||||
|
<h3>Load Average</h3> |
||||||
|
<p><strong>1 min:</strong> {{ health.load_average["1m"] }}</p> |
||||||
|
<p><strong>5 min:</strong> {{ health.load_average["5m"] }}</p> |
||||||
|
<p><strong>15 min:</strong> {{ health.load_average["15m"] }}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="health-card"> |
||||||
|
<h3>Memory</h3> |
||||||
|
<p><strong>Total:</strong> {{ health.memory.total_mb }} MB</p> |
||||||
|
<p><strong>Available:</strong> {{ health.memory.available_mb }} MB</p> |
||||||
|
<p><strong>Used:</strong> {{ health.memory.used_mb }} MB</p> |
||||||
|
<p><strong>Used %:</strong> {{ health.memory.used_percent }}%</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="health-card"> |
||||||
|
<h3>Disk /</h3> |
||||||
|
<p><strong>Total:</strong> {{ health.disk_root.total_gb }} GB</p> |
||||||
|
<p><strong>Used:</strong> {{ health.disk_root.used_gb }} GB</p> |
||||||
|
<p><strong>Free:</strong> {{ health.disk_root.free_gb }} GB</p> |
||||||
|
<p><strong>Used %:</strong> {{ health.disk_root.used_percent }}%</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,151 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { |
||||||
|
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
.portal-actions a { |
||||||
|
margin-left: 0.75rem; |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
.summary-grid { |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
||||||
|
gap:1rem; |
||||||
|
margin: 1rem 0 1.25rem 0; |
||||||
|
} |
||||||
|
.summary-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
} |
||||||
|
.summary-card h3 { margin-top:0; margin-bottom:0.4rem; } |
||||||
|
table.portal-table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
table.portal-table th, table.portal-table td { |
||||||
|
padding: 0.8rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.12); |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
table.portal-table th { |
||||||
|
background: #e9eef7; |
||||||
|
color: #10203f; |
||||||
|
} |
||||||
|
.invoice-link { |
||||||
|
color: inherit; |
||||||
|
text-decoration: underline; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
.status-badge { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.18rem 0.55rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.86rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
.status-paid { |
||||||
|
background: rgba(34, 197, 94, 0.18); |
||||||
|
color: #4ade80; |
||||||
|
} |
||||||
|
.status-pending { |
||||||
|
background: rgba(245, 158, 11, 0.20); |
||||||
|
color: #fbbf24; |
||||||
|
} |
||||||
|
.status-overdue { |
||||||
|
background: rgba(239, 68, 68, 0.18); |
||||||
|
color: #f87171; |
||||||
|
} |
||||||
|
.status-other { |
||||||
|
background: rgba(148, 163, 184, 0.20); |
||||||
|
color: #cbd5e1; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Client Dashboard</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div>{{ invoice_count }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div>{{ total_outstanding }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div>{{ total_paid }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoices</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,81 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Forgot Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Reset Portal Password</h1> |
||||||
|
<p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> |
||||||
|
|
||||||
|
{% if error %} |
||||||
|
<div class="portal-msg">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if message %} |
||||||
|
<div class="portal-msg">{{ message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/forgot-password"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" value="{{ form_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Send Reset Code</button> |
||||||
|
<a class="portal-btn" href="/portal">Back to Portal Login</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,166 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Invoice Detail - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { |
||||||
|
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
.portal-actions a { |
||||||
|
margin-left: 0.75rem; |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
.detail-grid { |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
||||||
|
gap:1rem; |
||||||
|
margin: 1rem 0 1.25rem 0; |
||||||
|
} |
||||||
|
.detail-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
} |
||||||
|
.detail-card h3 { |
||||||
|
margin-top: 0; |
||||||
|
margin-bottom: 0.4rem; |
||||||
|
} |
||||||
|
table.portal-table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
margin-top: 1rem; |
||||||
|
} |
||||||
|
table.portal-table th, table.portal-table td { |
||||||
|
padding: 0.8rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.12); |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
table.portal-table th { |
||||||
|
background: #e9eef7; |
||||||
|
color: #10203f; |
||||||
|
} |
||||||
|
.invoice-actions { |
||||||
|
margin-top: 1rem; |
||||||
|
} |
||||||
|
.invoice-actions a { |
||||||
|
margin-right: 1rem; |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
.status-badge { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.18rem 0.55rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.86rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
.status-paid { |
||||||
|
background: rgba(34, 197, 94, 0.18); |
||||||
|
color: #4ade80; |
||||||
|
} |
||||||
|
.status-pending { |
||||||
|
background: rgba(245, 158, 11, 0.20); |
||||||
|
color: #fbbf24; |
||||||
|
} |
||||||
|
.status-overdue { |
||||||
|
background: rgba(239, 68, 68, 0.18); |
||||||
|
color: #f87171; |
||||||
|
} |
||||||
|
.status-other { |
||||||
|
background: rgba(148, 163, 184, 0.20); |
||||||
|
color: #cbd5e1; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Invoice Detail</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/dashboard">Back to Dashboard</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="detail-grid"> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Invoice</h3> |
||||||
|
<div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div> |
||||||
|
</div> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Status</h3> |
||||||
|
{% set s = (invoice.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ invoice.status }}</span> |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ invoice.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ invoice.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ invoice.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Created</h3> |
||||||
|
<div>{{ invoice.created_at }}</div> |
||||||
|
</div> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Total</h3> |
||||||
|
<div>{{ invoice.total_amount }}</div> |
||||||
|
</div> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Paid</h3> |
||||||
|
<div>{{ invoice.amount_paid }}</div> |
||||||
|
</div> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Outstanding</h3> |
||||||
|
<div>{{ invoice.outstanding }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoice Items</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Description</th> |
||||||
|
<th>Qty</th> |
||||||
|
<th>Unit Price</th> |
||||||
|
<th>Line Total</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for item in items %} |
||||||
|
<tr> |
||||||
|
<td>{{ item.description }}</td> |
||||||
|
<td>{{ item.quantity }}</td> |
||||||
|
<td>{{ item.unit_price }}</td> |
||||||
|
<td>{{ item.line_total }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="4">No invoice line items found.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
|
||||||
|
{% if pdf_url %} |
||||||
|
<div class="invoice-actions"> |
||||||
|
<a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,95 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-note { margin-top: 1rem; opacity: 0.88; font-size: 0.95rem; } |
||||||
|
.portal-links { margin-top: 1rem; } |
||||||
|
.portal-links a { margin-right: 1rem; } |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="https://outsidethebox.top/">Home</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,75 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Set Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Create Your Portal Password</h1> |
||||||
|
<p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/set-password"> |
||||||
|
<div> |
||||||
|
<label for="password">New Password</label> |
||||||
|
<input id="password" name="password" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label for="password2">Confirm Password</label> |
||||||
|
<input id="password2" name="password2" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<button class="portal-btn" type="submit">Set Password</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,202 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html> |
||||||
|
<head> |
||||||
|
<title>Settings</title> |
||||||
|
<style> |
||||||
|
body { font-family: Arial, sans-serif; } |
||||||
|
.form-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: 1fr 1fr; |
||||||
|
gap: 24px; |
||||||
|
max-width: 1100px; |
||||||
|
} |
||||||
|
.card { |
||||||
|
border: 1px solid #ccc; |
||||||
|
padding: 16px; |
||||||
|
} |
||||||
|
.card h2 { |
||||||
|
margin-top: 0; |
||||||
|
} |
||||||
|
input[type="text"], |
||||||
|
input[type="email"], |
||||||
|
input[type="password"], |
||||||
|
input[type="number"], |
||||||
|
textarea, |
||||||
|
select { |
||||||
|
width: 100%; |
||||||
|
box-sizing: border-box; |
||||||
|
margin-top: 4px; |
||||||
|
margin-bottom: 12px; |
||||||
|
padding: 8px; |
||||||
|
} |
||||||
|
textarea { min-height: 90px; } |
||||||
|
.checkbox-row { |
||||||
|
margin: 8px 0 14px 0; |
||||||
|
} |
||||||
|
.save-row { |
||||||
|
margin-top: 18px; |
||||||
|
} |
||||||
|
.logo-preview { |
||||||
|
margin: 10px 0 14px 0; |
||||||
|
} |
||||||
|
.logo-preview img { |
||||||
|
max-height: 70px; |
||||||
|
max-width: 220px; |
||||||
|
border: 1px solid #ccc; |
||||||
|
padding: 6px; |
||||||
|
background: #fff; |
||||||
|
} |
||||||
|
small { |
||||||
|
color: #444; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
|
||||||
|
<h1>Settings / Config</h1> |
||||||
|
|
||||||
|
<p><a href="/">Home</a></p> |
||||||
|
|
||||||
|
<form method="post"> |
||||||
|
<div class="form-grid"> |
||||||
|
<div class="card"> |
||||||
|
<h2>Business Identity</h2> |
||||||
|
|
||||||
|
Business Name<br> |
||||||
|
<input type="text" name="business_name" value="{{ settings.business_name }}"><br> |
||||||
|
|
||||||
|
Business Logo URL<br> |
||||||
|
<input type="text" name="business_logo_url" value="{{ settings.business_logo_url }}"><br> |
||||||
|
<small>Example: /static/favicon.png or https://site.com/logo.png</small><br> |
||||||
|
|
||||||
|
{% if settings.business_logo_url %} |
||||||
|
<div class="logo-preview"> |
||||||
|
<img src="{{ settings.business_logo_url }}" alt="Business Logo Preview"> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
Slogan / Tagline<br> |
||||||
|
<input type="text" name="business_tagline" value="{{ settings.business_tagline }}"><br> |
||||||
|
|
||||||
|
Business Email<br> |
||||||
|
<input type="email" name="business_email" value="{{ settings.business_email }}"><br> |
||||||
|
|
||||||
|
Business Phone<br> |
||||||
|
<input type="text" name="business_phone" value="{{ settings.business_phone }}"><br> |
||||||
|
|
||||||
|
Business Address<br> |
||||||
|
<textarea name="business_address">{{ settings.business_address }}</textarea><br> |
||||||
|
|
||||||
|
Website<br> |
||||||
|
<input type="text" name="business_website" value="{{ settings.business_website }}"><br> |
||||||
|
|
||||||
|
Business Number / Registration Number<br> |
||||||
|
<input type="text" name="business_number" value="{{ settings.business_number }}"><br> |
||||||
|
|
||||||
|
Default Currency<br> |
||||||
|
<select name="default_currency"> |
||||||
|
<option value="CAD" {% if settings.default_currency == 'CAD' %}selected{% endif %}>CAD</option> |
||||||
|
<option value="USD" {% if settings.default_currency == 'USD' %}selected{% endif %}>USD</option> |
||||||
|
<option value="ETHO" {% if settings.default_currency == 'ETHO' %}selected{% endif %}>ETHO</option> |
||||||
|
<option value="EGAZ" {% if settings.default_currency == 'EGAZ' %}selected{% endif %}>EGAZ</option> |
||||||
|
<option value="ALT" {% if settings.default_currency == 'ALT' %}selected{% endif %}>ALT</option> |
||||||
|
</select> |
||||||
|
|
||||||
|
Report Frequency<br> |
||||||
|
<select name="report_frequency"> |
||||||
|
<option value="monthly" {% if settings.report_frequency == 'monthly' %}selected{% endif %}>monthly</option> |
||||||
|
<option value="quarterly" {% if settings.report_frequency == 'quarterly' %}selected{% endif %}>quarterly</option> |
||||||
|
<option value="yearly" {% if settings.report_frequency == 'yearly' %}selected{% endif %}>yearly</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="card"> |
||||||
|
<h2>Tax Settings</h2> |
||||||
|
|
||||||
|
Local Country<br> |
||||||
|
<input type="text" name="local_country" value="{{ settings.local_country }}"><br> |
||||||
|
|
||||||
|
Tax Label<br> |
||||||
|
<input type="text" name="tax_label" value="{{ settings.tax_label }}"><br> |
||||||
|
|
||||||
|
Tax Rate (%)<br> |
||||||
|
<input type="number" step="0.01" name="tax_rate" value="{{ settings.tax_rate }}"><br> |
||||||
|
|
||||||
|
Tax Number<br> |
||||||
|
<input type="text" name="tax_number" value="{{ settings.tax_number }}"><br> |
||||||
|
|
||||||
|
<div class="checkbox-row"> |
||||||
|
<label> |
||||||
|
<input type="checkbox" name="apply_local_tax_only" value="1" {% if settings.apply_local_tax_only == '1' %}checked{% endif %}> |
||||||
|
Apply tax only to local clients |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
|
||||||
|
Payment Terms<br> |
||||||
|
<textarea name="payment_terms">{{ settings.payment_terms }}</textarea><br> |
||||||
|
|
||||||
|
Invoice Footer<br> |
||||||
|
<textarea name="invoice_footer">{{ settings.invoice_footer }}</textarea><br> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="card"> |
||||||
|
<h2>Advanced / Email / SMTP</h2> |
||||||
|
|
||||||
|
SMTP Host<br> |
||||||
|
<input type="text" name="smtp_host" value="{{ settings.smtp_host }}"><br> |
||||||
|
|
||||||
|
SMTP Port<br> |
||||||
|
<input type="number" name="smtp_port" value="{{ settings.smtp_port }}"><br> |
||||||
|
|
||||||
|
SMTP Username<br> |
||||||
|
<input type="text" name="smtp_user" value="{{ settings.smtp_user }}"><br> |
||||||
|
|
||||||
|
SMTP Password<br> |
||||||
|
<input type="password" name="smtp_pass" value="{{ settings.smtp_pass }}"><br> |
||||||
|
|
||||||
|
From Email<br> |
||||||
|
<input type="email" name="smtp_from_email" value="{{ settings.smtp_from_email }}"><br> |
||||||
|
|
||||||
|
From Name<br> |
||||||
|
<input type="text" name="smtp_from_name" value="{{ settings.smtp_from_name }}"><br> |
||||||
|
|
||||||
|
Report / Accounting Delivery Email<br> |
||||||
|
<input type="email" name="report_delivery_email" value="{{ settings.report_delivery_email }}"><br> |
||||||
|
|
||||||
|
<div class="checkbox-row"> |
||||||
|
<label> |
||||||
|
<input type="checkbox" name="smtp_use_tls" value="1" {% if settings.smtp_use_tls == '1' %}checked{% endif %}> |
||||||
|
Use TLS |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="checkbox-row"> |
||||||
|
<label> |
||||||
|
<input type="checkbox" name="smtp_use_ssl" value="1" {% if settings.smtp_use_ssl == '1' %}checked{% endif %}> |
||||||
|
Use SSL |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="card"> |
||||||
|
<h2>Notes</h2> |
||||||
|
<p> |
||||||
|
Branding, tax identity, and SMTP values are stored here for this installation. |
||||||
|
</p> |
||||||
|
<p> |
||||||
|
Logo can be a local static path like <strong>/static/favicon.png</strong> or a full external/IPFS URL. |
||||||
|
</p> |
||||||
|
<p> |
||||||
|
Email sending is not wired yet, but these SMTP settings are stored now so the next step can use them. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="save-row"> |
||||||
|
<button type="submit">Save Settings</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,125 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-page-header"> |
||||||
|
<div> |
||||||
|
<h1 class="portal-page-title">Client Dashboard</h1> |
||||||
|
<p class="portal-client-name">{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
<p class="portal-page-subtitle">Invoices, balances, and account activity in one place.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-toolbar"> |
||||||
|
<a class="portal-btn primary" href="/portal/invoices/download-all">Download All Invoices</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
<a class="portal-btn" href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div class="summary-value">{{ invoice_count }}</div> |
||||||
|
<div class="summary-sub">Invoices currently visible in your portal</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div class="summary-value">{{ total_outstanding }}</div> |
||||||
|
<div class="summary-sub">Current unpaid balance</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div class="summary-value">{{ total_paid }}</div> |
||||||
|
<div class="summary-sub">Payments already applied</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2 class="section-title">Invoices</h2> |
||||||
|
|
||||||
|
<div class="table-card"> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% if row.payment_method_label %} |
||||||
|
<div class="payment-method |
||||||
|
{% if row.payment_method_label == "Square" %} payment-square{% elif row.payment_method_label == "e-Transfer" %} payment-etransfer{% elif row.payment_method_label == "ETHO" %} payment-etho{% elif row.payment_method_label == "ETI" or row.payment_method_label == "EGAZ" %} payment-etica{% elif row.payment_method_label == "ALT" %} payment-alt{% elif row.payment_method_label == "CAD" %} payment-cad{% endif %}"> |
||||||
|
{{ row.payment_method_label }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { window.location.reload(); }, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Square, e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Forgot Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } .portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Reset Portal Password</h1> <p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> {% if error %} <div class="portal-msg">{{ error }}</div> {% endif %} {% if message %} <div class="portal-msg">{{ message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/forgot-password"> <div> <label for="email">Email Address</label> <input id="email" name="email" type="email" value="{{ form_email or '' }}" required> </div> <div class="portal-actions"> <button class="portal-btn" type="submit">Send Reset Code</button> <a class="portal-btn" href="/portal">Back to Portal Login</a> <a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support" href="mailto:support@outsidethebox.top">Customer Support</a> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Square, e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,64 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1 class="portal-page-title">OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn primary" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Square, e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Set Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Create Your Portal Password</h1> <p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> {% if portal_message %} <div class="portal-msg">{{ portal_message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/set-password"> <div> <label for="password">New Password</label> <input id="password" name="password" type="password" required> </div> <div> <label for="password2">Confirm Password</label> <input id="password2" name="password2" type="password" required> </div> <div> <button class="portal-btn" type="submit">Set Password</button> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Square, e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,346 @@ |
|||||||
|
<style> |
||||||
|
:root { |
||||||
|
--otb-bg: #f5f7fb; |
||||||
|
--otb-text: #1e293b; |
||||||
|
--otb-card: #ffffff; |
||||||
|
--otb-border: #cbd5e1; |
||||||
|
--otb-link: #1d4ed8; |
||||||
|
--otb-muted: #64748b; |
||||||
|
--otb-shadow: 0 2px 8px rgba(15,23,42,0.08); |
||||||
|
--otb-hover: #eef4ff; |
||||||
|
|
||||||
|
--otb-success-bg: #dcfce7; |
||||||
|
--otb-success-text: #166534; |
||||||
|
|
||||||
|
--otb-warning-bg: #fef3c7; |
||||||
|
--otb-warning-text: #92400e; |
||||||
|
|
||||||
|
--otb-danger-bg: #fee2e2; |
||||||
|
--otb-danger-text: #991b1b; |
||||||
|
|
||||||
|
--otb-info-bg: #dbeafe; |
||||||
|
--otb-info-text: #1d4ed8; |
||||||
|
|
||||||
|
--otb-neutral-bg: #e5e7eb; |
||||||
|
--otb-neutral-text: #374151; |
||||||
|
} |
||||||
|
|
||||||
|
body.otb-dark { |
||||||
|
--otb-bg: #0f172a; |
||||||
|
--otb-text: #e5e7eb; |
||||||
|
--otb-card: #111827; |
||||||
|
--otb-border: #334155; |
||||||
|
--otb-link: #93c5fd; |
||||||
|
--otb-muted: #94a3b8; |
||||||
|
--otb-shadow: 0 2px 10px rgba(0,0,0,0.35); |
||||||
|
--otb-hover: #172554; |
||||||
|
|
||||||
|
--otb-success-bg: #052e16; |
||||||
|
--otb-success-text: #86efac; |
||||||
|
|
||||||
|
--otb-warning-bg: #451a03; |
||||||
|
--otb-warning-text: #fcd34d; |
||||||
|
|
||||||
|
--otb-danger-bg: #450a0a; |
||||||
|
--otb-danger-text: #fca5a5; |
||||||
|
|
||||||
|
--otb-info-bg: #172554; |
||||||
|
--otb-info-text: #93c5fd; |
||||||
|
|
||||||
|
--otb-neutral-bg: #1f2937; |
||||||
|
--otb-neutral-text: #d1d5db; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
background: var(--otb-bg); |
||||||
|
color: var(--otb-text); |
||||||
|
font-family: Arial, Helvetica, sans-serif; |
||||||
|
margin: 0; |
||||||
|
padding: 24px; |
||||||
|
transition: background 0.2s ease, color 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
a { |
||||||
|
color: var(--otb-link); |
||||||
|
} |
||||||
|
|
||||||
|
img { |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
button, |
||||||
|
input, |
||||||
|
select, |
||||||
|
textarea { |
||||||
|
font: inherit; |
||||||
|
} |
||||||
|
|
||||||
|
button { |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-page { |
||||||
|
max-width: 1240px; |
||||||
|
margin: 0 auto; |
||||||
|
background: var(--otb-card); |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
box-shadow: var(--otb-shadow); |
||||||
|
padding: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table, |
||||||
|
table { |
||||||
|
border-collapse: collapse; |
||||||
|
width: 100%; |
||||||
|
margin-top: 14px; |
||||||
|
background: var(--otb-card); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table th, |
||||||
|
.otb-table td, |
||||||
|
table th, |
||||||
|
table td { |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
padding: 8px 10px; |
||||||
|
vertical-align: top; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table th, |
||||||
|
table th { |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table tbody tr:hover, |
||||||
|
table tbody tr:hover, |
||||||
|
table tr:hover td { |
||||||
|
background: var(--otb-hover); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-money { |
||||||
|
text-align: right; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-alert { |
||||||
|
padding: 10px; |
||||||
|
margin-bottom: 15px; |
||||||
|
max-width: 950px; |
||||||
|
border: 1px solid; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-alert-success { |
||||||
|
background: var(--otb-success-bg); |
||||||
|
border-color: var(--otb-success-text); |
||||||
|
color: var(--otb-success-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-alert-error { |
||||||
|
background: var(--otb-danger-bg); |
||||||
|
border-color: var(--otb-danger-text); |
||||||
|
color: var(--otb-danger-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-footer { |
||||||
|
margin: 18px auto 0 auto; |
||||||
|
max-width: 1240px; |
||||||
|
font-size: 12px; |
||||||
|
color: var(--otb-muted); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-nav { |
||||||
|
display: flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
gap: 10px; |
||||||
|
margin: 18px 0 20px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-nav a, |
||||||
|
.otb-nav button { |
||||||
|
display: inline-block; |
||||||
|
text-decoration: none; |
||||||
|
padding: 9px 14px; |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
background: var(--otb-card); |
||||||
|
color: var(--otb-text); |
||||||
|
border-radius: 10px; |
||||||
|
box-shadow: var(--otb-shadow); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-nav a:hover, |
||||||
|
.otb-nav button:hover { |
||||||
|
background: var(--otb-hover); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-section-title { |
||||||
|
margin-top: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status { |
||||||
|
display: inline-block; |
||||||
|
padding: 3px 10px; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 12px; |
||||||
|
font-weight: bold; |
||||||
|
letter-spacing: 0.02em; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-paid, |
||||||
|
.otb-status-active, |
||||||
|
.otb-status-confirmed, |
||||||
|
.otb-status-sent { |
||||||
|
background: var(--otb-success-bg); |
||||||
|
color: var(--otb-success-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-pending, |
||||||
|
.otb-status-partial, |
||||||
|
.otb-status-warning, |
||||||
|
.otb-status-paused { |
||||||
|
background: var(--otb-warning-bg); |
||||||
|
color: var(--otb-warning-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-overdue, |
||||||
|
.otb-status-cancelled, |
||||||
|
.otb-status-reversed, |
||||||
|
.otb-status-failed { |
||||||
|
background: var(--otb-danger-bg); |
||||||
|
color: var(--otb-danger-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-draft, |
||||||
|
.otb-status-current, |
||||||
|
.otb-status-info { |
||||||
|
background: var(--otb-info-bg); |
||||||
|
color: var(--otb-info-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-neutral { |
||||||
|
background: var(--otb-neutral-bg); |
||||||
|
color: var(--otb-neutral-text); |
||||||
|
} |
||||||
|
|
||||||
|
/* Toggle switch */ |
||||||
|
.otb-switch { |
||||||
|
position: relative; |
||||||
|
display: inline-block; |
||||||
|
width: 46px; |
||||||
|
height: 24px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch input { |
||||||
|
opacity: 0; |
||||||
|
width: 0; |
||||||
|
height: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-slider { |
||||||
|
position: absolute; |
||||||
|
inset: 0; |
||||||
|
background: #cbd5e1; |
||||||
|
border-radius: 999px; |
||||||
|
transition: 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-slider:before { |
||||||
|
content: ""; |
||||||
|
position: absolute; |
||||||
|
height: 18px; |
||||||
|
width: 18px; |
||||||
|
left: 3px; |
||||||
|
bottom: 3px; |
||||||
|
background: white; |
||||||
|
border-radius: 50%; |
||||||
|
transition: 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch input:checked + .otb-slider { |
||||||
|
background: #2563eb; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch input:checked + .otb-slider:before { |
||||||
|
transform: translateX(22px); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<hr> |
||||||
|
<div class="otb-footer"> |
||||||
|
OTB Billing v{{ app_version }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function () { |
||||||
|
const themeKey = "otb_theme"; |
||||||
|
const toggle = document.getElementById("otbThemeToggle"); |
||||||
|
|
||||||
|
function applyTheme(theme) { |
||||||
|
if (theme === "dark") { |
||||||
|
document.body.classList.add("otb-dark"); |
||||||
|
if (toggle) toggle.checked = true; |
||||||
|
} else { |
||||||
|
document.body.classList.remove("otb-dark"); |
||||||
|
if (toggle) toggle.checked = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const savedTheme = localStorage.getItem(themeKey) || "light"; |
||||||
|
applyTheme(savedTheme); |
||||||
|
|
||||||
|
if (toggle) { |
||||||
|
toggle.addEventListener("change", function () { |
||||||
|
const nextTheme = toggle.checked ? "dark" : "light"; |
||||||
|
localStorage.setItem(themeKey, nextTheme); |
||||||
|
applyTheme(nextTheme); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function normalizeStatusText(text) { |
||||||
|
return (text || "") |
||||||
|
.trim() |
||||||
|
.toLowerCase() |
||||||
|
.replace(/\s+/g, "-"); |
||||||
|
} |
||||||
|
|
||||||
|
function statusClassFor(value) { |
||||||
|
const normalized = normalizeStatusText(value); |
||||||
|
|
||||||
|
if (["paid", "active", "confirmed", "sent"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-paid"; |
||||||
|
} |
||||||
|
if (["pending", "partial", "paused"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-pending"; |
||||||
|
} |
||||||
|
if (["overdue", "cancelled", "reversed", "failed"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-overdue"; |
||||||
|
} |
||||||
|
if (["draft", "current"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-draft"; |
||||||
|
} |
||||||
|
|
||||||
|
return ""; |
||||||
|
} |
||||||
|
|
||||||
|
function enhanceStatusCells() { |
||||||
|
const cells = document.querySelectorAll("td, span, div"); |
||||||
|
cells.forEach(function (el) { |
||||||
|
if (el.dataset.otbStatusDone === "1") return; |
||||||
|
if (el.children.length > 0) return; |
||||||
|
|
||||||
|
const text = (el.textContent || "").trim(); |
||||||
|
const klass = statusClassFor(text); |
||||||
|
|
||||||
|
if (!klass) return; |
||||||
|
|
||||||
|
if (el.tagName.toLowerCase() === "span") { |
||||||
|
el.className = (el.className ? el.className + " " : "") + klass; |
||||||
|
} else if (el.tagName.toLowerCase() === "td" || el.tagName.toLowerCase() === "div") { |
||||||
|
el.innerHTML = '<span class="' + klass + '">' + text + '</span>'; |
||||||
|
} |
||||||
|
|
||||||
|
el.dataset.otbStatusDone = "1"; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
enhanceStatusCells(); |
||||||
|
})(); |
||||||
|
</script> |
||||||
@ -0,0 +1,376 @@ |
|||||||
|
<style> |
||||||
|
:root { |
||||||
|
--otb-bg: #f5f7fb; |
||||||
|
--otb-text: #1e293b; |
||||||
|
--otb-card: #ffffff; |
||||||
|
--otb-border: #cbd5e1; |
||||||
|
--otb-link: #1d4ed8; |
||||||
|
--otb-muted: #64748b; |
||||||
|
--otb-shadow: 0 2px 8px rgba(15,23,42,0.08); |
||||||
|
--otb-hover: #eef4ff; |
||||||
|
|
||||||
|
--otb-success-bg: #dcfce7; |
||||||
|
--otb-success-text: #166534; |
||||||
|
|
||||||
|
--otb-warning-bg: #fef3c7; |
||||||
|
--otb-warning-text: #92400e; |
||||||
|
|
||||||
|
--otb-danger-bg: #fee2e2; |
||||||
|
--otb-danger-text: #991b1b; |
||||||
|
|
||||||
|
--otb-info-bg: #dbeafe; |
||||||
|
--otb-info-text: #1d4ed8; |
||||||
|
|
||||||
|
--otb-neutral-bg: #e5e7eb; |
||||||
|
--otb-neutral-text: #374151; |
||||||
|
} |
||||||
|
|
||||||
|
body.otb-dark { |
||||||
|
--otb-bg: #0f172a; |
||||||
|
--otb-text: #e5e7eb; |
||||||
|
--otb-card: #111827; |
||||||
|
--otb-border: #334155; |
||||||
|
--otb-link: #93c5fd; |
||||||
|
--otb-muted: #94a3b8; |
||||||
|
--otb-shadow: 0 2px 10px rgba(0,0,0,0.35); |
||||||
|
--otb-hover: #172554; |
||||||
|
|
||||||
|
--otb-success-bg: #052e16; |
||||||
|
--otb-success-text: #86efac; |
||||||
|
|
||||||
|
--otb-warning-bg: #451a03; |
||||||
|
--otb-warning-text: #fcd34d; |
||||||
|
|
||||||
|
--otb-danger-bg: #450a0a; |
||||||
|
--otb-danger-text: #fca5a5; |
||||||
|
|
||||||
|
--otb-info-bg: #172554; |
||||||
|
--otb-info-text: #93c5fd; |
||||||
|
|
||||||
|
--otb-neutral-bg: #1f2937; |
||||||
|
--otb-neutral-text: #d1d5db; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
background: var(--otb-bg); |
||||||
|
color: var(--otb-text); |
||||||
|
font-family: Arial, Helvetica, sans-serif; |
||||||
|
margin: 0; |
||||||
|
padding: 24px; |
||||||
|
transition: background 0.2s ease, color 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
a { |
||||||
|
color: var(--otb-link); |
||||||
|
} |
||||||
|
|
||||||
|
img { |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
button, |
||||||
|
input, |
||||||
|
select, |
||||||
|
textarea { |
||||||
|
font: inherit; |
||||||
|
} |
||||||
|
|
||||||
|
button { |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-page { |
||||||
|
max-width: 1240px; |
||||||
|
margin: 0 auto; |
||||||
|
background: var(--otb-card); |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
box-shadow: var(--otb-shadow); |
||||||
|
padding: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table, |
||||||
|
table { |
||||||
|
border-collapse: collapse; |
||||||
|
width: 100%; |
||||||
|
margin-top: 14px; |
||||||
|
background: var(--otb-card); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table th, |
||||||
|
.otb-table td, |
||||||
|
table th, |
||||||
|
table td { |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
padding: 8px 10px; |
||||||
|
vertical-align: top; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table th, |
||||||
|
table th { |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-table tbody tr:hover, |
||||||
|
table tbody tr:hover, |
||||||
|
table tr:hover td { |
||||||
|
background: var(--otb-hover); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-money { |
||||||
|
text-align: right; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-alert { |
||||||
|
padding: 10px; |
||||||
|
margin-bottom: 15px; |
||||||
|
max-width: 950px; |
||||||
|
border: 1px solid; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-alert-success { |
||||||
|
background: var(--otb-success-bg); |
||||||
|
border-color: var(--otb-success-text); |
||||||
|
color: var(--otb-success-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-alert-error { |
||||||
|
background: var(--otb-danger-bg); |
||||||
|
border-color: var(--otb-danger-text); |
||||||
|
color: var(--otb-danger-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-footer { |
||||||
|
margin: 18px auto 0 auto; |
||||||
|
max-width: 1240px; |
||||||
|
font-size: 12px; |
||||||
|
color: var(--otb-muted); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-nav { |
||||||
|
display: flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
gap: 10px; |
||||||
|
margin: 18px 0 20px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-nav a, |
||||||
|
.otb-nav button { |
||||||
|
display: inline-block; |
||||||
|
text-decoration: none; |
||||||
|
padding: 9px 14px; |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
background: var(--otb-card); |
||||||
|
color: var(--otb-text); |
||||||
|
border-radius: 10px; |
||||||
|
box-shadow: var(--otb-shadow); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-nav a:hover, |
||||||
|
.otb-nav button:hover { |
||||||
|
background: var(--otb-hover); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-section-title { |
||||||
|
margin-top: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status { |
||||||
|
display: inline-block; |
||||||
|
padding: 3px 10px; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 12px; |
||||||
|
font-weight: bold; |
||||||
|
letter-spacing: 0.02em; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-paid, |
||||||
|
.otb-status-active, |
||||||
|
.otb-status-confirmed, |
||||||
|
.otb-status-sent { |
||||||
|
background: var(--otb-success-bg); |
||||||
|
color: var(--otb-success-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-pending, |
||||||
|
.otb-status-partial, |
||||||
|
.otb-status-warning, |
||||||
|
.otb-status-paused { |
||||||
|
background: var(--otb-warning-bg); |
||||||
|
color: var(--otb-warning-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-overdue, |
||||||
|
.otb-status-cancelled, |
||||||
|
.otb-status-reversed, |
||||||
|
.otb-status-failed { |
||||||
|
background: var(--otb-danger-bg); |
||||||
|
color: var(--otb-danger-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-draft, |
||||||
|
.otb-status-current, |
||||||
|
.otb-status-info { |
||||||
|
background: var(--otb-info-bg); |
||||||
|
color: var(--otb-info-text); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-status-neutral { |
||||||
|
background: var(--otb-neutral-bg); |
||||||
|
color: var(--otb-neutral-text); |
||||||
|
} |
||||||
|
|
||||||
|
/* Toggle switch */ |
||||||
|
.otb-theme-toggle-wrap { |
||||||
|
position: fixed; |
||||||
|
top: 12px; |
||||||
|
right: 12px; |
||||||
|
z-index: 9999; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 8px; |
||||||
|
background: var(--otb-card); |
||||||
|
color: var(--otb-text); |
||||||
|
border: 1px solid var(--otb-border); |
||||||
|
box-shadow: var(--otb-shadow); |
||||||
|
border-radius: 999px; |
||||||
|
padding: 8px 12px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-theme-toggle-wrap .label { |
||||||
|
font-size: 12px; |
||||||
|
color: var(--otb-muted); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch { |
||||||
|
position: relative; |
||||||
|
display: inline-block; |
||||||
|
width: 46px; |
||||||
|
height: 24px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch input { |
||||||
|
opacity: 0; |
||||||
|
width: 0; |
||||||
|
height: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-slider { |
||||||
|
position: absolute; |
||||||
|
inset: 0; |
||||||
|
background: #cbd5e1; |
||||||
|
border-radius: 999px; |
||||||
|
transition: 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-slider:before { |
||||||
|
content: ""; |
||||||
|
position: absolute; |
||||||
|
height: 18px; |
||||||
|
width: 18px; |
||||||
|
left: 3px; |
||||||
|
bottom: 3px; |
||||||
|
background: white; |
||||||
|
border-radius: 50%; |
||||||
|
transition: 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch input:checked + .otb-slider { |
||||||
|
background: #2563eb; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-switch input:checked + .otb-slider:before { |
||||||
|
transform: translateX(22px); |
||||||
|
} |
||||||
|
</style> |
||||||
|
|
||||||
|
<div class="otb-theme-toggle-wrap"> |
||||||
|
<span class="label">Theme</span> |
||||||
|
<label class="otb-switch"> |
||||||
|
<input type="checkbox" id="otbThemeToggle"> |
||||||
|
<span class="otb-slider"></span> |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
|
||||||
|
<hr> |
||||||
|
<div class="otb-footer"> |
||||||
|
OTB Billing v{{ app_version }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function () { |
||||||
|
const themeKey = "otb_theme"; |
||||||
|
const toggle = document.getElementById("otbThemeToggle"); |
||||||
|
|
||||||
|
function applyTheme(theme) { |
||||||
|
if (theme === "dark") { |
||||||
|
document.body.classList.add("otb-dark"); |
||||||
|
if (toggle) toggle.checked = true; |
||||||
|
} else { |
||||||
|
document.body.classList.remove("otb-dark"); |
||||||
|
if (toggle) toggle.checked = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const savedTheme = localStorage.getItem(themeKey) || "light"; |
||||||
|
applyTheme(savedTheme); |
||||||
|
|
||||||
|
if (toggle) { |
||||||
|
toggle.addEventListener("change", function () { |
||||||
|
const nextTheme = toggle.checked ? "dark" : "light"; |
||||||
|
localStorage.setItem(themeKey, nextTheme); |
||||||
|
applyTheme(nextTheme); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function normalizeStatusText(text) { |
||||||
|
return (text || "") |
||||||
|
.trim() |
||||||
|
.toLowerCase() |
||||||
|
.replace(/\s+/g, "-"); |
||||||
|
} |
||||||
|
|
||||||
|
function statusClassFor(value) { |
||||||
|
const normalized = normalizeStatusText(value); |
||||||
|
|
||||||
|
if (["paid", "active", "confirmed", "sent"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-paid"; |
||||||
|
} |
||||||
|
if (["pending", "partial", "paused"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-pending"; |
||||||
|
} |
||||||
|
if (["overdue", "cancelled", "reversed", "failed"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-overdue"; |
||||||
|
} |
||||||
|
if (["draft", "current"].includes(normalized)) { |
||||||
|
return "otb-status otb-status-draft"; |
||||||
|
} |
||||||
|
|
||||||
|
return ""; |
||||||
|
} |
||||||
|
|
||||||
|
function enhanceStatusCells() { |
||||||
|
const cells = document.querySelectorAll("td, span, div"); |
||||||
|
cells.forEach(function (el) { |
||||||
|
if (el.dataset.otbStatusDone === "1") return; |
||||||
|
if (el.children.length > 0) return; |
||||||
|
|
||||||
|
const text = (el.textContent || "").trim(); |
||||||
|
const klass = statusClassFor(text); |
||||||
|
|
||||||
|
if (!klass) return; |
||||||
|
|
||||||
|
if (el.tagName.toLowerCase() === "span") { |
||||||
|
el.className = (el.className ? el.className + " " : "") + klass; |
||||||
|
} else if (el.tagName.toLowerCase() === "td" || el.tagName.toLowerCase() === "div") { |
||||||
|
el.innerHTML = '<span class="' + klass + '">' + text + '</span>'; |
||||||
|
} |
||||||
|
|
||||||
|
el.dataset.otbStatusDone = "1"; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
enhanceStatusCells(); |
||||||
|
})(); |
||||||
|
</script> |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,107 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-page-header"> |
||||||
|
<div> |
||||||
|
<h1 class="portal-page-title">Client Dashboard</h1> |
||||||
|
<p class="portal-client-name">{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
<p class="portal-page-subtitle">Invoices, balances, and account activity in one place.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-toolbar"> |
||||||
|
<a class="portal-btn primary" href="/portal/invoices/download-all">Download All Invoices</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
<a class="portal-btn" href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div class="summary-value">{{ invoice_count }}</div> |
||||||
|
<div class="summary-sub">Invoices currently visible in your portal</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div class="summary-value">{{ total_outstanding }}</div> |
||||||
|
<div class="summary-sub">Current unpaid balance</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div class="summary-value">{{ total_paid }}</div> |
||||||
|
<div class="summary-sub">Payments already applied</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2 class="section-title">Invoices</h2> |
||||||
|
|
||||||
|
<div class="table-card"> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { window.location.reload(); }, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,107 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-page-header"> |
||||||
|
<div> |
||||||
|
<h1 class="portal-page-title">Client Dashboard</h1> |
||||||
|
<p class="portal-client-name">{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
<p class="portal-page-subtitle">Invoices, balances, and account activity in one place.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-toolbar"> |
||||||
|
<a class="portal-btn primary" href="/portal/invoices/download-all">Download All Invoices</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
<a class="portal-btn" href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div class="summary-value">{{ invoice_count }}</div> |
||||||
|
<div class="summary-sub">Invoices currently visible in your portal</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div class="summary-value">{{ total_outstanding }}</div> |
||||||
|
<div class="summary-sub">Current unpaid balance</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div class="summary-value">{{ total_paid }}</div> |
||||||
|
<div class="summary-sub">Payments already applied</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2 class="section-title">Invoices</h2> |
||||||
|
|
||||||
|
<div class="table-card"> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { window.location.reload(); }, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,110 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-page-header"> |
||||||
|
<div> |
||||||
|
<h1 class="portal-page-title">Client Dashboard</h1> |
||||||
|
<p class="portal-client-name">{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
<p class="portal-page-subtitle">Invoices, balances, and account activity in one place.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-toolbar"> |
||||||
|
<a class="portal-btn primary" href="/portal/invoices/download-all">Download All Invoices</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
<a class="portal-btn" href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div class="summary-value">{{ invoice_count }}</div> |
||||||
|
<div class="summary-sub">Invoices currently visible in your portal</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div class="summary-value">{{ total_outstanding }}</div> |
||||||
|
<div class="summary-sub">Current unpaid balance</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div class="summary-value">{{ total_paid }}</div> |
||||||
|
<div class="summary-sub">Payments already applied</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2 class="section-title">Invoices</h2> |
||||||
|
|
||||||
|
<div class="table-card"> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% if row.payment_method_label %} |
||||||
|
<div class="summary-sub" style="margin-top:6px;">via {{ row.payment_method_label }}</div> |
||||||
|
{% endif %} |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { window.location.reload(); }, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Forgot Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } .portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Reset Portal Password</h1> <p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> {% if error %} <div class="portal-msg">{{ error }}</div> {% endif %} {% if message %} <div class="portal-msg">{{ message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/forgot-password"> <div> <label for="email">Email Address</label> <input id="email" name="email" type="email" value="{{ form_email or '' }}" required> </div> <div class="portal-actions"> <button class="portal-btn" type="submit">Send Reset Code</button> <a class="portal-btn" href="/portal">Back to Portal Login</a> <a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support" href="mailto:support@outsidethebox.top">Customer Support</a> </div> </form> </div> |
||||||
|
</div> {% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,52 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1 class="portal-page-title">OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn primary" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Set Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Create Your Portal Password</h1> <p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> {% if portal_message %} <div class="portal-msg">{{ portal_message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/set-password"> <div> <label for="password">New Password</label> <input id="password" name="password" type="password" required> </div> <div> <label for="password2">Confirm Password</label> <input id="password2" name="password2" type="password" required> </div> <div> <button class="portal-btn" type="submit">Set Password</button> </div> </form> </div> |
||||||
|
</div> {% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,587 @@ |
|||||||
|
:root{ |
||||||
|
--bg:#0b0f14; |
||||||
|
--card:#121825; |
||||||
|
--card-soft:rgba(18,24,37,.78); |
||||||
|
--text:#e8eefc; |
||||||
|
--muted:#aab6d6; |
||||||
|
--line:#24304a; |
||||||
|
--accent:#7aa2ff; |
||||||
|
--accent2:#62e6b7; |
||||||
|
--success:#4ade80; |
||||||
|
--warn:#fbbf24; |
||||||
|
--danger:#f87171; |
||||||
|
--radius:16px; |
||||||
|
--shadow:0 16px 40px rgba(0,0,0,.35); |
||||||
|
} |
||||||
|
|
||||||
|
*{box-sizing:border-box} |
||||||
|
html,body{height:100%} |
||||||
|
|
||||||
|
body{ |
||||||
|
margin:0; |
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji","Segoe UI Emoji"; |
||||||
|
background: |
||||||
|
radial-gradient(1200px 600px at 20% 0%, rgba(122,162,255,.22), transparent 60%), |
||||||
|
radial-gradient(900px 500px at 90% 20%, rgba(98,230,183,.15), transparent 60%), |
||||||
|
linear-gradient(180deg, #081225 0%, #09172d 100%); |
||||||
|
color:var(--text); |
||||||
|
line-height:1.45; |
||||||
|
} |
||||||
|
|
||||||
|
a{color:inherit} |
||||||
|
|
||||||
|
/* ===== Shared container ===== */ |
||||||
|
.container{ |
||||||
|
max-width:1100px; |
||||||
|
margin:0 auto; |
||||||
|
padding:20px 18px; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Header / branded nav ===== */ |
||||||
|
.header{width:100%} |
||||||
|
|
||||||
|
.nav{ |
||||||
|
display:flex; |
||||||
|
align-items:center; |
||||||
|
justify-content:space-between; |
||||||
|
gap:18px; |
||||||
|
padding:12px 0 22px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.brand{ |
||||||
|
display:flex; |
||||||
|
align-items:center; |
||||||
|
gap:14px; |
||||||
|
text-decoration:none; |
||||||
|
} |
||||||
|
|
||||||
|
.brand img{ |
||||||
|
height:60px; |
||||||
|
width:auto; |
||||||
|
display:block; |
||||||
|
object-fit:contain; |
||||||
|
background: rgba(255,255,255,0.92); |
||||||
|
padding: 6px 12px; |
||||||
|
border-radius: 999px; |
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.35); |
||||||
|
} |
||||||
|
|
||||||
|
.title{ |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
line-height:1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.title strong{letter-spacing:.2px} |
||||||
|
.title span{ |
||||||
|
color:var(--muted); |
||||||
|
font-size:13px; |
||||||
|
margin-top:2px; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks{ |
||||||
|
display:flex; |
||||||
|
gap:12px; |
||||||
|
flex-wrap:wrap; |
||||||
|
justify-content:flex-end; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a{ |
||||||
|
text-decoration:none; |
||||||
|
padding:8px 10px; |
||||||
|
border-radius:12px; |
||||||
|
color:var(--muted); |
||||||
|
border:1px solid transparent; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a:hover{ |
||||||
|
color:var(--text); |
||||||
|
border-color:rgba(255,255,255,.08); |
||||||
|
background:rgba(255,255,255,.03); |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a.active{ |
||||||
|
color:var(--text); |
||||||
|
border-color:rgba(255,255,255,.10); |
||||||
|
background:rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Generic portal shell ===== */ |
||||||
|
.portal-shell{ |
||||||
|
max-width:1100px; |
||||||
|
margin:16px auto 28px auto; |
||||||
|
padding:0 18px 20px 18px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-card, |
||||||
|
.detail-card, |
||||||
|
.summary-card, |
||||||
|
.pay-card{ |
||||||
|
background: var(--card-soft); |
||||||
|
border: 1px solid rgba(255,255,255,.07); |
||||||
|
border-radius: var(--radius); |
||||||
|
box-shadow: var(--shadow); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-card{ |
||||||
|
max-width:760px; |
||||||
|
margin:24px auto 12px auto; |
||||||
|
padding:22px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-header{ |
||||||
|
display:flex; |
||||||
|
align-items:flex-start; |
||||||
|
justify-content:space-between; |
||||||
|
gap:16px; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin:6px 0 18px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-title{ |
||||||
|
margin:0; |
||||||
|
font-size:24px; |
||||||
|
line-height:1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-subtitle{ |
||||||
|
margin:8px 0 0 0; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-client-name{ |
||||||
|
margin:8px 0 0 0; |
||||||
|
color:var(--text); |
||||||
|
font-size:15px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-toolbar{ |
||||||
|
display:flex; |
||||||
|
gap:10px; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn, |
||||||
|
.btn, |
||||||
|
.pay-btn, |
||||||
|
.quote-pick-btn{ |
||||||
|
display:inline-flex; |
||||||
|
align-items:center; |
||||||
|
justify-content:center; |
||||||
|
gap:8px; |
||||||
|
min-height:42px; |
||||||
|
padding:10px 14px; |
||||||
|
border-radius:12px; |
||||||
|
text-decoration:none; |
||||||
|
border:1px solid rgba(255,255,255,.10); |
||||||
|
background: rgba(255,255,255,.05); |
||||||
|
color:var(--text); |
||||||
|
font-weight:600; |
||||||
|
cursor:pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn:hover, |
||||||
|
.btn:hover, |
||||||
|
.pay-btn:hover, |
||||||
|
.quote-pick-btn:hover{ |
||||||
|
border-color:rgba(122,162,255,.45); |
||||||
|
box-shadow:0 0 0 4px rgba(122,162,255,.12); |
||||||
|
text-decoration:none; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn.primary, |
||||||
|
.btn.primary{ |
||||||
|
background: linear-gradient(135deg, rgba(122,162,255,.95), rgba(98,230,183,.85)); |
||||||
|
border-color: transparent; |
||||||
|
color:#071017; |
||||||
|
font-weight:700; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn.primary:hover, |
||||||
|
.btn.primary:hover{ |
||||||
|
box-shadow:0 0 0 4px rgba(98,230,183,.18); |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Login / forms ===== */ |
||||||
|
.portal-sub{ |
||||||
|
color:var(--muted); |
||||||
|
margin:0 0 16px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form{ |
||||||
|
display:grid; |
||||||
|
gap:14px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form label{ |
||||||
|
display:block; |
||||||
|
font-weight:600; |
||||||
|
margin-bottom:6px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form input, |
||||||
|
.portal-form select, |
||||||
|
.pay-selector{ |
||||||
|
width:100%; |
||||||
|
padding:12px 14px; |
||||||
|
border-radius:12px; |
||||||
|
border:1px solid rgba(255,255,255,.14); |
||||||
|
background: rgba(255,255,255,.06); |
||||||
|
color:var(--text); |
||||||
|
box-sizing:border-box; |
||||||
|
outline:none; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form input:focus, |
||||||
|
.portal-form select:focus, |
||||||
|
.pay-selector:focus{ |
||||||
|
border-color:rgba(122,162,255,.65); |
||||||
|
box-shadow:0 0 0 4px rgba(122,162,255,.12); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-actions{ |
||||||
|
display:flex; |
||||||
|
gap:10px; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:4px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-note{ |
||||||
|
margin-top:16px; |
||||||
|
color:var(--muted); |
||||||
|
font-size:14px; |
||||||
|
line-height:1.5; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-msg, |
||||||
|
.error-box, |
||||||
|
.success-box{ |
||||||
|
margin-bottom:16px; |
||||||
|
padding:12px 14px; |
||||||
|
border-radius:12px; |
||||||
|
border:1px solid rgba(255,255,255,.16); |
||||||
|
background: rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
.error-box{ |
||||||
|
border-color: rgba(239, 68, 68, 0.55); |
||||||
|
background: rgba(127, 29, 29, 0.22); |
||||||
|
color: #fecaca; |
||||||
|
} |
||||||
|
|
||||||
|
.success-box{ |
||||||
|
border-color: rgba(34, 197, 94, 0.55); |
||||||
|
background: rgba(22, 101, 52, 0.18); |
||||||
|
color: #dcfce7; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Dashboard ===== */ |
||||||
|
.portal-wrap{ |
||||||
|
max-width:1100px; |
||||||
|
margin:0 auto; |
||||||
|
padding:0; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-grid, |
||||||
|
.detail-grid{ |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)); |
||||||
|
gap:14px; |
||||||
|
margin: 0 0 18px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card, |
||||||
|
.detail-card{ |
||||||
|
padding:18px; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card h3, |
||||||
|
.detail-card h3{ |
||||||
|
margin:0 0 8px 0; |
||||||
|
font-size:14px; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card .summary-value, |
||||||
|
.detail-card .detail-value{ |
||||||
|
font-size:28px; |
||||||
|
font-weight:800; |
||||||
|
line-height:1.1; |
||||||
|
color:var(--text); |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card .summary-sub{ |
||||||
|
margin-top:6px; |
||||||
|
font-size:12px; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.section-title{ |
||||||
|
margin:18px 0 10px 0; |
||||||
|
font-size:22px; |
||||||
|
} |
||||||
|
|
||||||
|
.table-card{ |
||||||
|
background: var(--card-soft); |
||||||
|
border: 1px solid rgba(255,255,255,.07); |
||||||
|
border-radius: var(--radius); |
||||||
|
box-shadow: var(--shadow); |
||||||
|
overflow:hidden; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Tables ===== */ |
||||||
|
table, |
||||||
|
.portal-table, |
||||||
|
.quote-table{ |
||||||
|
width:100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table th, |
||||||
|
table.portal-table td, |
||||||
|
.quote-table th, |
||||||
|
.quote-table td{ |
||||||
|
padding: 0.9rem 0.85rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.10); |
||||||
|
text-align: left; |
||||||
|
vertical-align: middle; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table th, |
||||||
|
.quote-table th{ |
||||||
|
background: rgba(255,255,255,.06); |
||||||
|
color: var(--text); |
||||||
|
font-size:13px; |
||||||
|
letter-spacing:.02em; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table tr:hover td, |
||||||
|
.quote-table tr:hover td{ |
||||||
|
background: rgba(255,255,255,.02); |
||||||
|
} |
||||||
|
|
||||||
|
.invoice-link{ |
||||||
|
color: var(--text); |
||||||
|
text-decoration: none; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.invoice-link:hover{ |
||||||
|
color: var(--accent); |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Badges ===== */ |
||||||
|
.status-badge, |
||||||
|
.quote-badge{ |
||||||
|
display: inline-block; |
||||||
|
padding: 0.22rem 0.62rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.82rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.status-paid{ background: rgba(34, 197, 94, 0.18); color: var(--success); } |
||||||
|
.status-pending{ background: rgba(245, 158, 11, 0.20); color: var(--warn); } |
||||||
|
.status-overdue{ background: rgba(239, 68, 68, 0.18); color: var(--danger); } |
||||||
|
.status-other{ background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } |
||||||
|
|
||||||
|
.quote-live{ background: rgba(34, 197, 94, 0.18); color: var(--success); } |
||||||
|
.quote-stale{ background: rgba(239, 68, 68, 0.18); color: var(--danger); } |
||||||
|
|
||||||
|
/* ===== Payments / invoice detail ===== */ |
||||||
|
.pay-card{ |
||||||
|
padding:18px; |
||||||
|
margin-top: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pay-selector-row{ |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
align-items:center; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pay-panel{ |
||||||
|
margin-top: 1rem; |
||||||
|
padding: 1rem; |
||||||
|
border: 1px solid rgba(255,255,255,0.12); |
||||||
|
border-radius: 12px; |
||||||
|
background: rgba(255,255,255,0.02); |
||||||
|
} |
||||||
|
|
||||||
|
.pay-panel.hidden{ display:none; } |
||||||
|
|
||||||
|
.pay-btn-square { background:#16a34a; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-wallet { background:#2563eb; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-mobile { background:#7c3aed; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-copy { background:#374151; border-color:transparent; color:#fff; } |
||||||
|
|
||||||
|
.snapshot-wrap{ |
||||||
|
position: relative; |
||||||
|
margin-top: 1rem; |
||||||
|
border: 1px solid rgba(255,255,255,0.14); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.02); |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-header{ |
||||||
|
display:flex; |
||||||
|
justify-content:space-between; |
||||||
|
gap:1rem; |
||||||
|
align-items:flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-meta{ |
||||||
|
flex: 1 1 auto; |
||||||
|
min-width: 0; |
||||||
|
line-height: 1.65; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-box{ |
||||||
|
width: 220px; |
||||||
|
min-height: 132px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
background: rgba(0,0,0,0.18); |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
justify-content:center; |
||||||
|
align-items:center; |
||||||
|
text-align:center; |
||||||
|
padding: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-value{ |
||||||
|
font-size: 2rem; |
||||||
|
font-weight: 800; |
||||||
|
line-height: 1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-label{ |
||||||
|
margin-top: 0.55rem; |
||||||
|
font-size: 0.95rem; |
||||||
|
opacity: 0.95; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-expired{ color: var(--danger); } |
||||||
|
|
||||||
|
.lock-box{ |
||||||
|
margin-top: 1rem; |
||||||
|
border: 1px solid rgba(34, 197, 94, 0.28); |
||||||
|
background: rgba(22, 101, 52, 0.16); |
||||||
|
border-radius: 12px; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.lock-box.expired{ |
||||||
|
border-color: rgba(239, 68, 68, 0.55); |
||||||
|
background: rgba(127, 29, 29, 0.22); |
||||||
|
} |
||||||
|
|
||||||
|
.lock-grid{ |
||||||
|
display:grid; |
||||||
|
grid-template-columns: 1fr 220px; |
||||||
|
gap:1rem; |
||||||
|
align-items:start; |
||||||
|
} |
||||||
|
|
||||||
|
.lock-code{ |
||||||
|
display:block; |
||||||
|
margin-top:0.35rem; |
||||||
|
padding:0.65rem 0.8rem; |
||||||
|
background: rgba(0,0,0,0.22); |
||||||
|
border-radius: 8px; |
||||||
|
overflow-wrap:anywhere; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-actions{ |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.9rem; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help{ |
||||||
|
margin-top: 0.85rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
border: 1px solid rgba(255,255,255,0.10); |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help h4{ |
||||||
|
margin: 0 0 0.55rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help p{ margin: 0.35rem 0; } |
||||||
|
.wallet-note{ opacity:0.9; margin-top:0.65rem; } |
||||||
|
.mono{ font-family: monospace; } |
||||||
|
|
||||||
|
.copy-row{ |
||||||
|
display:flex; |
||||||
|
gap:0.5rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
margin-top:0.65rem; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-target{ |
||||||
|
flex: 1 1 420px; |
||||||
|
min-width: 220px; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-status{ |
||||||
|
display:inline-block; |
||||||
|
margin-left: 0.5rem; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Footer ===== */ |
||||||
|
footer{ |
||||||
|
margin:28px auto 20px auto; |
||||||
|
max-width:1100px; |
||||||
|
padding:0 18px; |
||||||
|
color:var(--muted); |
||||||
|
font-size:13px; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Responsive ===== */ |
||||||
|
@media (max-width: 900px){ |
||||||
|
.summary-grid, |
||||||
|
.detail-grid{ |
||||||
|
grid-template-columns:1fr; |
||||||
|
} |
||||||
|
|
||||||
|
.nav{ |
||||||
|
align-items:flex-start; |
||||||
|
flex-direction:column; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks{ |
||||||
|
justify-content:flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.brand img{ |
||||||
|
height:54px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 820px){ |
||||||
|
.snapshot-header, |
||||||
|
.lock-grid{ |
||||||
|
grid-template-columns: 1fr; |
||||||
|
display:block; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-box{ |
||||||
|
width: 100%; |
||||||
|
margin-top: 1rem; |
||||||
|
min-height: 110px; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,170 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { |
||||||
|
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
.portal-actions a { |
||||||
|
margin-left: 0.75rem; |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
.summary-grid { |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
||||||
|
gap:1rem; |
||||||
|
margin: 1rem 0 1.25rem 0; |
||||||
|
} |
||||||
|
.summary-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
} |
||||||
|
.summary-card h3 { margin-top:0; margin-bottom:0.4rem; } |
||||||
|
table.portal-table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
table.portal-table th, table.portal-table td { |
||||||
|
padding: 0.8rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.12); |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
table.portal-table th { |
||||||
|
background: #e9eef7; |
||||||
|
color: #10203f; |
||||||
|
} |
||||||
|
.invoice-link { |
||||||
|
color: inherit; |
||||||
|
text-decoration: underline; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
.status-badge { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.18rem 0.55rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.86rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
.status-paid { |
||||||
|
background: rgba(34, 197, 94, 0.18); |
||||||
|
color: #4ade80; |
||||||
|
} |
||||||
|
.status-pending { |
||||||
|
background: rgba(245, 158, 11, 0.20); |
||||||
|
color: #fbbf24; |
||||||
|
} |
||||||
|
.status-overdue { |
||||||
|
background: rgba(239, 68, 68, 0.18); |
||||||
|
color: #f87171; |
||||||
|
} |
||||||
|
.status-other { |
||||||
|
background: rgba(148, 163, 184, 0.20); |
||||||
|
color: #cbd5e1; |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Client Dashboard</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/invoices/download-all">Download All Invoices (ZIP)</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div>{{ invoice_count }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div>{{ total_outstanding }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div>{{ total_paid }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoices</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { |
||||||
|
window.location.reload(); |
||||||
|
}, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Forgot Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Reset Portal Password</h1> |
||||||
|
<p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> |
||||||
|
|
||||||
|
{% if error %} |
||||||
|
<div class="portal-msg">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if message %} |
||||||
|
<div class="portal-msg">{{ message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/forgot-password"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" value="{{ form_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Send Reset Code</button> |
||||||
|
<a class="portal-btn" href="/portal">Back to Portal Login</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,735 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Invoice Detail - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom: 1rem; } |
||||||
|
.portal-actions a { margin-left: 0.75rem; text-decoration: underline; } |
||||||
|
.detail-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin: 1rem 0 1.25rem 0; } |
||||||
|
.detail-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-bottom: 1rem; } |
||||||
|
.detail-card h3 { margin-top: 0; margin-bottom: 0.4rem; } |
||||||
|
table.portal-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } |
||||||
|
table.portal-table th, table.portal-table td { padding: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; } |
||||||
|
table.portal-table th { background: #e9eef7; color: #10203f; } |
||||||
|
.status-badge { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.86rem; font-weight: 700; } |
||||||
|
.status-paid { background: rgba(34, 197, 94, 0.18); color: #4ade80; } |
||||||
|
.status-pending { background: rgba(245, 158, 11, 0.20); color: #fbbf24; } |
||||||
|
.status-overdue { background: rgba(239, 68, 68, 0.18); color: #f87171; } |
||||||
|
.status-other { background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } |
||||||
|
|
||||||
|
.pay-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-top: 1.25rem; } |
||||||
|
.pay-selector-row { display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; margin-top:0.75rem; } |
||||||
|
.pay-selector { padding: 10px 12px; min-width: 220px; border-radius: 8px; } |
||||||
|
.pay-panel { margin-top: 1rem; padding: 1rem; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(255,255,255,0.02); } |
||||||
|
.pay-panel.hidden { display: none; } |
||||||
|
|
||||||
|
.pay-btn { |
||||||
|
display:inline-block; |
||||||
|
padding:12px 18px; |
||||||
|
color:#ffffff; |
||||||
|
text-decoration:none; |
||||||
|
border-radius:8px; |
||||||
|
font-weight:700; |
||||||
|
border:none; |
||||||
|
cursor:pointer; |
||||||
|
margin:8px 0 0 0; |
||||||
|
} |
||||||
|
.pay-btn-square { background:#16a34a; } |
||||||
|
.pay-btn-wallet { background:#2563eb; } |
||||||
|
.pay-btn-mobile { background:#7c3aed; } |
||||||
|
.pay-btn-copy { background:#374151; } |
||||||
|
|
||||||
|
.error-box { border: 1px solid rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); color: #fecaca; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } |
||||||
|
.success-box { border: 1px solid rgba(34, 197, 94, 0.55); background: rgba(22, 101, 52, 0.18); color: #dcfce7; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } |
||||||
|
|
||||||
|
.snapshot-wrap { position: relative; margin-top: 1rem; border: 1px solid rgba(255,255,255,0.14); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.02); } |
||||||
|
.snapshot-header { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; } |
||||||
|
.snapshot-meta { flex: 1 1 auto; min-width: 0; line-height: 1.65; } |
||||||
|
.snapshot-timer-box { |
||||||
|
width: 220px; |
||||||
|
min-height: 132px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
background: rgba(0,0,0,0.18); |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
justify-content:center; |
||||||
|
align-items:center; |
||||||
|
text-align:center; |
||||||
|
padding: 0.9rem; |
||||||
|
} |
||||||
|
.snapshot-timer-value { font-size: 2rem; font-weight: 800; line-height: 1.1; } |
||||||
|
.snapshot-timer-label { margin-top: 0.55rem; font-size: 0.95rem; opacity: 0.95; } |
||||||
|
.snapshot-timer-expired { color: #f87171; } |
||||||
|
|
||||||
|
.quote-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } |
||||||
|
.quote-table th, .quote-table td { padding: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; vertical-align: top; } |
||||||
|
.quote-table th { background: #e9eef7; color: #10203f; } |
||||||
|
.quote-badge { display: inline-block; padding: 0.14rem 0.48rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; margin-left: 0.4rem; } |
||||||
|
.quote-live { background: rgba(34, 197, 94, 0.18); color: #4ade80; } |
||||||
|
.quote-stale { background: rgba(239, 68, 68, 0.18); color: #f87171; } |
||||||
|
.quote-pick-btn { padding: 8px 12px; border-radius: 8px; border: none; background: #2563eb; color: #fff; font-weight: 700; cursor: pointer; } |
||||||
|
.quote-pick-btn[disabled] { opacity: 0.5; cursor: not-allowed; } |
||||||
|
|
||||||
|
.lock-box { margin-top: 1rem; border: 1px solid rgba(34, 197, 94, 0.28); background: rgba(22, 101, 52, 0.16); border-radius: 12px; padding: 1rem; } |
||||||
|
.lock-box.expired { border-color: rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); } |
||||||
|
.lock-grid { display:grid; grid-template-columns: 1fr 220px; gap:1rem; align-items:start; } |
||||||
|
.lock-code { display:block; margin-top:0.35rem; padding:0.65rem 0.8rem; background: rgba(0,0,0,0.22); border-radius: 8px; overflow-wrap:anywhere; } |
||||||
|
|
||||||
|
.wallet-actions { |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.9rem; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
.wallet-help { |
||||||
|
margin-top: 0.85rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
border: 1px solid rgba(255,255,255,0.10); |
||||||
|
} |
||||||
|
.wallet-help h4 { |
||||||
|
margin: 0 0 0.55rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
.wallet-help p { |
||||||
|
margin: 0.35rem 0; |
||||||
|
} |
||||||
|
.wallet-note { opacity:0.9; margin-top:0.65rem; } |
||||||
|
.mono { font-family: monospace; } |
||||||
|
.copy-row { |
||||||
|
display:flex; |
||||||
|
gap:0.5rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
margin-top:0.65rem; |
||||||
|
} |
||||||
|
.copy-target { |
||||||
|
flex: 1 1 420px; |
||||||
|
min-width: 220px; |
||||||
|
} |
||||||
|
.copy-status { |
||||||
|
display:inline-block; |
||||||
|
margin-left: 0.5rem; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 820px) { |
||||||
|
.snapshot-header, .lock-grid { grid-template-columns: 1fr; display:block; } |
||||||
|
.snapshot-timer-box { width: 100%; margin-top: 1rem; min-height: 110px; } |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Invoice Detail</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/dashboard">Back to Dashboard</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if (invoice.status or "")|lower == "paid" %} |
||||||
|
<div class="success-box">✓ This invoice has been paid. Thank you!</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if crypto_error %} |
||||||
|
<div class="error-box">{{ crypto_error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<div class="detail-grid"> |
||||||
|
<div class="detail-card"><h3>Invoice</h3><div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div></div> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Status</h3> |
||||||
|
{% set s = (invoice.status or "")|lower %} |
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} |
||||||
|
<span class="status-badge status-pending">processing</span> |
||||||
|
{% elif s == "paid" %}<span class="status-badge status-paid">{{ invoice.status }}</span> |
||||||
|
{% elif s == "pending" %}<span class="status-badge status-pending">{{ invoice.status }}</span> |
||||||
|
{% elif s == "overdue" %}<span class="status-badge status-overdue">{{ invoice.status }}</span> |
||||||
|
{% else %}<span class="status-badge status-other">{{ invoice.status }}</span>{% endif %} |
||||||
|
</div> |
||||||
|
<div class="detail-card"><h3>Created</h3><div>{{ invoice.created_at }}</div></div> |
||||||
|
<div class="detail-card"><h3>Total</h3><div>{{ invoice.total_amount }}</div></div> |
||||||
|
<div class="detail-card"><h3>Paid</h3><div>{{ invoice.amount_paid }}</div></div> |
||||||
|
<div class="detail-card"><h3>Outstanding</h3><div>{{ invoice.outstanding }}</div></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoice Items</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Line Total</th></tr></thead> |
||||||
|
<tbody> |
||||||
|
{% for item in items %} |
||||||
|
<tr><td>{{ item.description }}</td><td>{{ item.quantity }}</td><td>{{ item.unit_price }}</td><td>{{ item.line_total }}</td></tr> |
||||||
|
{% else %} |
||||||
|
<tr><td colspan="4">No invoice line items found.</td></tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
|
||||||
|
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %} |
||||||
|
<div class="pay-card"> |
||||||
|
<h3>Pay Now</h3> |
||||||
|
<div class="pay-selector-row"> |
||||||
|
<label for="payMethodSelect"><strong>Choose payment method:</strong></label> |
||||||
|
<select id="payMethodSelect" class="pay-selector"> |
||||||
|
<option value="" {% if not pay_mode %}selected{% endif %}>Select…</option> |
||||||
|
<option value="etransfer" {% if pay_mode == "etransfer" %}selected{% endif %}>e-Transfer</option> |
||||||
|
<option value="square" {% if pay_mode == "square" %}selected{% endif %}>Credit Card</option> |
||||||
|
<option value="crypto" {% if pay_mode == "crypto" %}selected{% endif %}>Crypto</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}"> |
||||||
|
<p><strong>Interac e-Transfer</strong><br>Send payment to:<br>payment@outsidethebox.top<br>Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}"> |
||||||
|
<p><strong>Credit Card (Square)</strong></p> |
||||||
|
<a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-square">Pay with Credit Card</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}"> |
||||||
|
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %} |
||||||
|
<div class="snapshot-wrap"> |
||||||
|
<div class="snapshot-header"> |
||||||
|
<div class="snapshot-meta"> |
||||||
|
<h3 style="margin-top:0;">Crypto Quote Snapshot</h3> |
||||||
|
<div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_at or "—" }}</div> |
||||||
|
<div><strong>Source Status:</strong> {{ invoice.oracle_quote.source_status or "—" }}</div> |
||||||
|
<div><strong>Frozen Amount:</strong> {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}</div> |
||||||
|
{% if pending_crypto_payment %} |
||||||
|
<div style="margin-top:0.75rem;"><strong>Your quote is protected after acceptance.</strong></div> |
||||||
|
{% else %} |
||||||
|
<div style="margin-top:0.75rem;"><strong>Select a crypto asset to accept the quote.</strong></div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="processingTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div> |
||||||
|
<div id="processingTimerLabel" class="snapshot-timer-label">Watching transaction / waiting for confirmation</div> |
||||||
|
</div> |
||||||
|
{% elif pending_crypto_payment %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="lockTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div> |
||||||
|
<div id="lockTimerLabel" class="snapshot-timer-label">Quote protected while you open wallet</div> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="quoteTimerValue" class="snapshot-timer-value" data-expiry="{{ crypto_quote_window_expires_iso }}">--:--</div> |
||||||
|
<div id="quoteTimerLabel" class="snapshot-timer-label">This price times out:</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if pending_crypto_payment and selected_crypto_option %} |
||||||
|
<div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired or pending_crypto_payment.processing_expired %} expired{% endif %}"> |
||||||
|
<div class="lock-grid"> |
||||||
|
<div> |
||||||
|
<h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3> |
||||||
|
<div><strong>Send exactly:</strong> {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}</div> |
||||||
|
<div style="margin-top:0.65rem;"><strong>Destination wallet:</strong></div> |
||||||
|
<code id="walletAddressText" class="lock-code copy-target">{{ pending_crypto_payment.wallet_address }}</code> |
||||||
|
<div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div> |
||||||
|
<code id="invoiceRefText" class="lock-code copy-target">{{ pending_crypto_payment.reference }}</code> |
||||||
|
|
||||||
|
{% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %} |
||||||
|
<div class="wallet-actions"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
id="walletPayButton" |
||||||
|
class="pay-btn pay-btn-wallet" |
||||||
|
data-invoice-id="{{ invoice.id }}" |
||||||
|
data-payment-id="{{ pending_crypto_payment.id }}" |
||||||
|
data-asset="{{ selected_crypto_option.symbol }}" |
||||||
|
data-chain-id="{{ selected_crypto_option.chain_id }}" |
||||||
|
data-asset-type="{{ selected_crypto_option.asset_type }}" |
||||||
|
data-to="{{ selected_crypto_option.wallet_address }}" |
||||||
|
data-amount="{{ pending_crypto_payment.payment_amount }}" |
||||||
|
data-decimals="{{ selected_crypto_option.decimals }}" |
||||||
|
data-token-contract="{{ selected_crypto_option.token_contract or '' }}" |
||||||
|
data-chain-add='{{ (selected_crypto_option.chain_add_params or {})|tojson|safe }}' |
||||||
|
> |
||||||
|
Open MetaMask / Rabby |
||||||
|
</button> |
||||||
|
|
||||||
|
<a |
||||||
|
id="metamaskMobileLink" |
||||||
|
href="#" |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
class="pay-btn pay-btn-mobile" |
||||||
|
data-invoice-id="{{ invoice.id }}" |
||||||
|
> |
||||||
|
Open in MetaMask Mobile |
||||||
|
</a> |
||||||
|
|
||||||
|
<button type="button" id="copyDetailsButton" class="pay-btn pay-btn-copy"> |
||||||
|
Copy Payment Details |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="wallet-help"> |
||||||
|
<h4>Fastest way to pay</h4> |
||||||
|
<p>1. Click <strong>Open MetaMask / Rabby</strong> if your wallet is installed in this browser.</p> |
||||||
|
<p>2. If that does not open your wallet, click <strong>Open in MetaMask Mobile</strong>.</p> |
||||||
|
<p>3. If needed, use <strong>Copy Payment Details</strong> and send manually.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="wallet-note"> |
||||||
|
You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet. |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="copy-row"> |
||||||
|
<span id="walletStatusText"></span> |
||||||
|
<span id="copyStatusText" class="copy-status"></span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% elif pending_crypto_payment.txid %} |
||||||
|
<div style="margin-top:0.9rem;"><strong>Transaction Hash:</strong></div> |
||||||
|
<code class="lock-code mono">{{ pending_crypto_payment.txid }}</code> |
||||||
|
<div style="margin-top:0.75rem;">Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.</div> |
||||||
|
{% elif pending_crypto_payment.lock_expired %} |
||||||
|
<div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto"> |
||||||
|
<table class="quote-table"> |
||||||
|
<thead><tr><th>Asset</th><th>Quoted Amount</th><th>CAD Price</th><th>Status</th><th>Action</th></tr></thead> |
||||||
|
<tbody> |
||||||
|
{% for q in crypto_options %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
{{ q.label }} |
||||||
|
{% if q.recommended %}<span class="quote-badge quote-live">recommended</span>{% endif %} |
||||||
|
{% if q.wallet_capable %}<span class="quote-badge quote-live">wallet</span>{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ q.display_amount or "—" }}</td> |
||||||
|
<td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td> |
||||||
|
<td>{% if q.available %}<span class="quote-badge quote-live">live</span>{% else %}<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>{% endif %}</td> |
||||||
|
<td><button type="submit" name="asset" value="{{ q.symbol }}" class="quote-pick-btn" {% if not q.available %}disabled{% endif %}>Accept {{ q.symbol }}</button></td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</form> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<p>No crypto quote snapshot is available for this invoice yet.</p> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if invoice_payments %} |
||||||
|
<div class="detail-card" style="margin-top:1.25rem;"> |
||||||
|
<h3>Payments Applied</h3> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Method</th> |
||||||
|
<th>Amount</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Received</th> |
||||||
|
<th>Reference / TXID</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for p in invoice_payments %} |
||||||
|
<tr> |
||||||
|
<td>{{ p.payment_method_label }}</td> |
||||||
|
<td>{{ p.payment_amount_display }} {{ p.payment_currency }}</td> |
||||||
|
<td>{{ p.payment_status }}</td> |
||||||
|
<td>{{ p.received_at_local }}</td> |
||||||
|
<td> |
||||||
|
{% if p.txid %} |
||||||
|
{{ p.txid }} |
||||||
|
{% elif p.reference %} |
||||||
|
{{ p.reference }} |
||||||
|
{% else %} |
||||||
|
- |
||||||
|
{% endif %} |
||||||
|
{% if p.wallet_address %}<br><small>{{ p.wallet_address }}</small>{% endif %} |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if pdf_url %} |
||||||
|
<div style="margin-top:1rem;"><a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a></div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
const select = document.getElementById("payMethodSelect"); |
||||||
|
if (select) { |
||||||
|
select.addEventListener("change", function() { |
||||||
|
const value = this.value || ""; |
||||||
|
const url = new URL(window.location.href); |
||||||
|
if (!value) { |
||||||
|
url.searchParams.delete("pay"); |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.delete("refresh_quote"); |
||||||
|
} else { |
||||||
|
url.searchParams.set("pay", value); |
||||||
|
if (value !== "crypto") { |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.delete("refresh_quote"); |
||||||
|
} else { |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.set("refresh_quote", "1"); |
||||||
|
} |
||||||
|
} |
||||||
|
window.location.href = url.toString(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function bindCountdown(valueId, labelId, expireIso, expiredMessage, disableSelector) { |
||||||
|
const valueEl = document.getElementById(valueId); |
||||||
|
const labelEl = document.getElementById(labelId); |
||||||
|
if (!valueEl || !expireIso) return; |
||||||
|
|
||||||
|
function tick() { |
||||||
|
const end = new Date(expireIso).getTime(); |
||||||
|
const now = Date.now(); |
||||||
|
const diff = Math.max(0, Math.floor((end - now) / 1000)); |
||||||
|
|
||||||
|
if (diff <= 0) { |
||||||
|
valueEl.textContent = "00:00"; |
||||||
|
valueEl.classList.add("snapshot-timer-expired"); |
||||||
|
if (labelEl) { |
||||||
|
labelEl.textContent = expiredMessage; |
||||||
|
labelEl.classList.add("snapshot-timer-expired"); |
||||||
|
} |
||||||
|
if (disableSelector) { |
||||||
|
document.querySelectorAll(disableSelector).forEach(btn => btn.disabled = true); |
||||||
|
} |
||||||
|
const lockBox = document.getElementById("lockBox"); |
||||||
|
if (lockBox) lockBox.classList.add("expired"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const m = String(Math.floor(diff / 60)).padStart(2, "0"); |
||||||
|
const s = String(diff % 60).padStart(2, "0"); |
||||||
|
valueEl.textContent = `${m}:${s}`; |
||||||
|
setTimeout(tick, 250); |
||||||
|
} |
||||||
|
|
||||||
|
tick(); |
||||||
|
} |
||||||
|
|
||||||
|
const quoteTimer = document.getElementById("quoteTimerValue"); |
||||||
|
if (quoteTimer && quoteTimer.dataset.expiry) { |
||||||
|
bindCountdown("quoteTimerValue", "quoteTimerLabel", quoteTimer.dataset.expiry, "price has expired - please refresh your view to update", "#cryptoPickForm button"); |
||||||
|
} |
||||||
|
|
||||||
|
const lockTimer = document.getElementById("lockTimerValue"); |
||||||
|
if (lockTimer && lockTimer.dataset.expiry) { |
||||||
|
bindCountdown("lockTimerValue", "lockTimerLabel", lockTimer.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton"); |
||||||
|
} |
||||||
|
|
||||||
|
const processingTimer = document.getElementById("processingTimerValue"); |
||||||
|
if (processingTimer && processingTimer.dataset.expiry) { |
||||||
|
bindCountdown("processingTimerValue", "processingTimerLabel", processingTimer.dataset.expiry, "price has expired - please refresh your quote to update", null); |
||||||
|
} |
||||||
|
|
||||||
|
function toHexBigIntFromDecimal(amountText, decimals) { |
||||||
|
const text = String(amountText || "0"); |
||||||
|
const parts = text.split("."); |
||||||
|
const whole = parts[0] || "0"; |
||||||
|
const frac = (parts[1] || "").padEnd(decimals, "0").slice(0, decimals); |
||||||
|
const combined = (whole + frac).replace(/^0+/, "") || "0"; |
||||||
|
return "0x" + BigInt(combined).toString(16); |
||||||
|
} |
||||||
|
|
||||||
|
function erc20TransferData(to, amountText, decimals) { |
||||||
|
const method = "a9059cbb"; |
||||||
|
const addr = String(to || "").toLowerCase().replace(/^0x/, "").padStart(64, "0"); |
||||||
|
const amtHex = BigInt(toHexBigIntFromDecimal(amountText, decimals)).toString(16).padStart(64, "0"); |
||||||
|
return "0x" + method + addr + amtHex; |
||||||
|
} |
||||||
|
|
||||||
|
async function switchChain(chainId, chainAddParams) { |
||||||
|
const hexChainId = "0x" + Number(chainId).toString(16); |
||||||
|
try { |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_switchEthereumChain", |
||||||
|
params: [{ chainId: hexChainId }] |
||||||
|
}); |
||||||
|
return; |
||||||
|
} catch (err) { |
||||||
|
const code = err && (err.code ?? err?.data?.originalError?.code); |
||||||
|
if ((code === 4902 || String(err).includes("4902")) && chainAddParams) { |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_addEthereumChain", |
||||||
|
params: [chainAddParams] |
||||||
|
}); |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_switchEthereumChain", |
||||||
|
params: [{ chainId: hexChainId }] |
||||||
|
}); |
||||||
|
return; |
||||||
|
} |
||||||
|
throw err; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function buildMetaMaskMobileLink() { |
||||||
|
const currentUrl = window.location.href; |
||||||
|
return "https://link.metamask.io/dapp/" + currentUrl.replace(/^https?:\/\//, ""); |
||||||
|
} |
||||||
|
|
||||||
|
const mmLink = document.getElementById("metamaskMobileLink"); |
||||||
|
if (mmLink) { |
||||||
|
mmLink.href = buildMetaMaskMobileLink(); |
||||||
|
} |
||||||
|
|
||||||
|
async function copyText(text) { |
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) { |
||||||
|
await navigator.clipboard.writeText(text); |
||||||
|
return; |
||||||
|
} |
||||||
|
const ta = document.createElement("textarea"); |
||||||
|
ta.value = text; |
||||||
|
document.body.appendChild(ta); |
||||||
|
ta.select(); |
||||||
|
document.execCommand("copy"); |
||||||
|
ta.remove(); |
||||||
|
} |
||||||
|
|
||||||
|
const copyBtn = document.getElementById("copyDetailsButton"); |
||||||
|
if (copyBtn) { |
||||||
|
copyBtn.addEventListener("click", async function() { |
||||||
|
const copyStatus = document.getElementById("copyStatusText"); |
||||||
|
const walletAddress = document.getElementById("walletAddressText")?.textContent || ""; |
||||||
|
const invoiceRef = document.getElementById("invoiceRefText")?.textContent || ""; |
||||||
|
const amount = document.getElementById("walletPayButton")?.dataset.amount || ""; |
||||||
|
const asset = document.getElementById("walletPayButton")?.dataset.asset || ""; |
||||||
|
|
||||||
|
const payload = |
||||||
|
`Asset: ${asset} |
||||||
|
Amount: ${amount} ${asset} |
||||||
|
Wallet: ${walletAddress} |
||||||
|
Reference: ${invoiceRef}`; |
||||||
|
|
||||||
|
try { |
||||||
|
await copyText(payload); |
||||||
|
if (copyStatus) copyStatus.textContent = "Payment details copied."; |
||||||
|
} catch (err) { |
||||||
|
if (copyStatus) copyStatus.textContent = "Copy failed."; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const walletButton = document.getElementById("walletPayButton"); |
||||||
|
|
||||||
|
function pendingTxStorageKey(invoiceId, paymentId) { |
||||||
|
return `otb_pending_tx_${invoiceId}_${paymentId}`; |
||||||
|
} |
||||||
|
|
||||||
|
async function submitTxHash(invoiceId, paymentId, asset, txHash) { |
||||||
|
const res = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { |
||||||
|
method: "POST", |
||||||
|
headers: { |
||||||
|
"Content-Type": "application/json", |
||||||
|
"Accept": "application/json" |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
payment_id: paymentId, |
||||||
|
asset: asset, |
||||||
|
tx_hash: txHash |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
let data = {}; |
||||||
|
try { |
||||||
|
data = await res.json(); |
||||||
|
} catch (err) { |
||||||
|
data = { ok: false, error: "invalid_json_response" }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!res.ok || !data.ok) { |
||||||
|
throw new Error(data.error || `submit_failed_http_${res.status}`); |
||||||
|
} |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
async function tryRecoverPendingTxFromStorage() { |
||||||
|
const invoiceId = "{{ invoice.id }}"; |
||||||
|
const paymentId = "{{ pending_crypto_payment.id if pending_crypto_payment else '' }}"; |
||||||
|
const asset = "{{ selected_crypto_option.symbol if selected_crypto_option else '' }}"; |
||||||
|
|
||||||
|
if (!invoiceId || !paymentId || !asset) return; |
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||||
|
return; |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
const key = pendingTxStorageKey(invoiceId, paymentId); |
||||||
|
const savedTx = localStorage.getItem(key); |
||||||
|
if (!savedTx || !savedTx.startsWith("0x")) return; |
||||||
|
|
||||||
|
const walletStatus = document.getElementById("walletStatusText"); |
||||||
|
try { |
||||||
|
if (walletStatus) walletStatus.textContent = "Retrying saved transaction submission..."; |
||||||
|
await submitTxHash(invoiceId, paymentId, asset, savedTx); |
||||||
|
localStorage.removeItem(key); |
||||||
|
const url = new URL(window.location.href); |
||||||
|
url.searchParams.set("pay", "crypto"); |
||||||
|
url.searchParams.set("asset", asset); |
||||||
|
url.searchParams.set("payment_id", paymentId); |
||||||
|
window.location.href = url.toString(); |
||||||
|
} catch (err) { |
||||||
|
if (walletStatus) walletStatus.textContent = `Saved tx retry failed: ${err.message}`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (walletButton) { |
||||||
|
walletButton.addEventListener("click", async function() { |
||||||
|
const walletStatus = document.getElementById("walletStatusText"); |
||||||
|
const invoiceId = this.dataset.invoiceId; |
||||||
|
const paymentId = this.dataset.paymentId; |
||||||
|
const asset = this.dataset.asset; |
||||||
|
const chainId = this.dataset.chainId; |
||||||
|
const assetType = this.dataset.assetType; |
||||||
|
const to = this.dataset.to; |
||||||
|
const amount = this.dataset.amount; |
||||||
|
const decimals = Number(this.dataset.decimals || "18"); |
||||||
|
const tokenContract = this.dataset.tokenContract || ""; |
||||||
|
let chainAddParams = null; |
||||||
|
try { |
||||||
|
chainAddParams = this.dataset.chainAdd ? JSON.parse(this.dataset.chainAdd) : null; |
||||||
|
} catch (err) { |
||||||
|
chainAddParams = null; |
||||||
|
} |
||||||
|
|
||||||
|
const setStatus = (msg) => { |
||||||
|
if (walletStatus) walletStatus.textContent = msg; |
||||||
|
}; |
||||||
|
|
||||||
|
if (!window.ethereum || !window.ethereum.request) { |
||||||
|
setStatus("No browser wallet detected. Use MetaMask/Rabby or MetaMask Mobile."); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
this.disabled = true; |
||||||
|
setStatus("Opening wallet..."); |
||||||
|
|
||||||
|
await window.ethereum.request({ method: "eth_requestAccounts" }); |
||||||
|
|
||||||
|
if (chainId && chainId !== "None" && chainId !== "") { |
||||||
|
try { |
||||||
|
await switchChain(Number(chainId), chainAddParams); |
||||||
|
} catch (err) { |
||||||
|
setStatus(`Chain switch failed: ${err.message || err}`); |
||||||
|
this.disabled = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let txParams; |
||||||
|
if (assetType === "token" && tokenContract) { |
||||||
|
txParams = { |
||||||
|
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||||
|
to: tokenContract, |
||||||
|
data: erc20TransferData(to, amount, decimals), |
||||||
|
value: "0x0" |
||||||
|
}; |
||||||
|
} else { |
||||||
|
txParams = { |
||||||
|
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||||
|
to: to, |
||||||
|
value: toHexBigIntFromDecimal(amount, decimals) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
setStatus("Waiting for wallet confirmation..."); |
||||||
|
const txHash = await window.ethereum.request({ |
||||||
|
method: "eth_sendTransaction", |
||||||
|
params: [txParams] |
||||||
|
}); |
||||||
|
|
||||||
|
if (!txHash || !String(txHash).startsWith("0x")) { |
||||||
|
throw new Error("wallet did not return a tx hash"); |
||||||
|
} |
||||||
|
|
||||||
|
const storageKey = pendingTxStorageKey(invoiceId, paymentId); |
||||||
|
localStorage.setItem(storageKey, String(txHash)); |
||||||
|
|
||||||
|
setStatus(`Wallet submitted tx: ${txHash}. Sending to billing server...`); |
||||||
|
|
||||||
|
await submitTxHash(invoiceId, paymentId, asset, txHash); |
||||||
|
|
||||||
|
localStorage.removeItem(storageKey); |
||||||
|
|
||||||
|
setStatus("Transaction submitted. Reloading into processing view..."); |
||||||
|
const url = new URL(window.location.href); |
||||||
|
url.searchParams.set("pay", "crypto"); |
||||||
|
url.searchParams.set("asset", asset); |
||||||
|
url.searchParams.set("payment_id", paymentId); |
||||||
|
window.location.href = url.toString(); |
||||||
|
} catch (err) { |
||||||
|
setStatus(`Wallet submit failed: ${err.message || err}`); |
||||||
|
this.disabled = false; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
tryRecoverPendingTxFromStorage(); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
const processingAutoRefreshEnabled = {{ 'true' if pending_crypto_payment and pending_crypto_payment.txid and (invoice.status or '')|lower != 'paid' else 'false' }}; |
||||||
|
if (processingAutoRefreshEnabled) { |
||||||
|
setTimeout(function() { |
||||||
|
window.location.reload(); |
||||||
|
}, 10000); |
||||||
|
} |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,104 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-note { margin-top: 1rem; opacity: 0.88; font-size: 0.95rem; } |
||||||
|
.portal-links { margin-top: 1rem; } |
||||||
|
.portal-links a { margin-right: 1rem; } |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="https://outsidethebox.top/">Home</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,84 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Set Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Create Your Portal Password</h1> |
||||||
|
<p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/set-password"> |
||||||
|
<div> |
||||||
|
<label for="password">New Password</label> |
||||||
|
<input id="password" name="password" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label for="password2">Confirm Password</label> |
||||||
|
<input id="password2" name="password2" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<button class="portal-btn" type="submit">Set Password</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,170 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { |
||||||
|
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
.portal-actions a { |
||||||
|
margin-left: 0.75rem; |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
.summary-grid { |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
||||||
|
gap:1rem; |
||||||
|
margin: 1rem 0 1.25rem 0; |
||||||
|
} |
||||||
|
.summary-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
} |
||||||
|
.summary-card h3 { margin-top:0; margin-bottom:0.4rem; } |
||||||
|
table.portal-table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
table.portal-table th, table.portal-table td { |
||||||
|
padding: 0.8rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.12); |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
table.portal-table th { |
||||||
|
background: #e9eef7; |
||||||
|
color: #10203f; |
||||||
|
} |
||||||
|
.invoice-link { |
||||||
|
color: inherit; |
||||||
|
text-decoration: underline; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
.status-badge { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.18rem 0.55rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.86rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
.status-paid { |
||||||
|
background: rgba(34, 197, 94, 0.18); |
||||||
|
color: #4ade80; |
||||||
|
} |
||||||
|
.status-pending { |
||||||
|
background: rgba(245, 158, 11, 0.20); |
||||||
|
color: #fbbf24; |
||||||
|
} |
||||||
|
.status-overdue { |
||||||
|
background: rgba(239, 68, 68, 0.18); |
||||||
|
color: #f87171; |
||||||
|
} |
||||||
|
.status-other { |
||||||
|
background: rgba(148, 163, 184, 0.20); |
||||||
|
color: #cbd5e1; |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Client Dashboard</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/invoices/download-all">Download All Invoices (ZIP)</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div>{{ invoice_count }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div>{{ total_outstanding }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div>{{ total_paid }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoices</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { |
||||||
|
window.location.reload(); |
||||||
|
}, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Forgot Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Reset Portal Password</h1> |
||||||
|
<p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> |
||||||
|
|
||||||
|
{% if error %} |
||||||
|
<div class="portal-msg">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if message %} |
||||||
|
<div class="portal-msg">{{ message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/forgot-password"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" value="{{ form_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Send Reset Code</button> |
||||||
|
<a class="portal-btn" href="/portal">Back to Portal Login</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,735 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Invoice Detail - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom: 1rem; } |
||||||
|
.portal-actions a { margin-left: 0.75rem; text-decoration: underline; } |
||||||
|
.detail-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin: 1rem 0 1.25rem 0; } |
||||||
|
.detail-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-bottom: 1rem; } |
||||||
|
.detail-card h3 { margin-top: 0; margin-bottom: 0.4rem; } |
||||||
|
table.portal-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } |
||||||
|
table.portal-table th, table.portal-table td { padding: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; } |
||||||
|
table.portal-table th { background: #e9eef7; color: #10203f; } |
||||||
|
.status-badge { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.86rem; font-weight: 700; } |
||||||
|
.status-paid { background: rgba(34, 197, 94, 0.18); color: #4ade80; } |
||||||
|
.status-pending { background: rgba(245, 158, 11, 0.20); color: #fbbf24; } |
||||||
|
.status-overdue { background: rgba(239, 68, 68, 0.18); color: #f87171; } |
||||||
|
.status-other { background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } |
||||||
|
|
||||||
|
.pay-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-top: 1.25rem; } |
||||||
|
.pay-selector-row { display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; margin-top:0.75rem; } |
||||||
|
.pay-selector { padding: 10px 12px; min-width: 220px; border-radius: 8px; } |
||||||
|
.pay-panel { margin-top: 1rem; padding: 1rem; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(255,255,255,0.02); } |
||||||
|
.pay-panel.hidden { display: none; } |
||||||
|
|
||||||
|
.pay-btn { |
||||||
|
display:inline-block; |
||||||
|
padding:12px 18px; |
||||||
|
color:#ffffff; |
||||||
|
text-decoration:none; |
||||||
|
border-radius:8px; |
||||||
|
font-weight:700; |
||||||
|
border:none; |
||||||
|
cursor:pointer; |
||||||
|
margin:8px 0 0 0; |
||||||
|
} |
||||||
|
.pay-btn-square { background:#16a34a; } |
||||||
|
.pay-btn-wallet { background:#2563eb; } |
||||||
|
.pay-btn-mobile { background:#7c3aed; } |
||||||
|
.pay-btn-copy { background:#374151; } |
||||||
|
|
||||||
|
.error-box { border: 1px solid rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); color: #fecaca; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } |
||||||
|
.success-box { border: 1px solid rgba(34, 197, 94, 0.55); background: rgba(22, 101, 52, 0.18); color: #dcfce7; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } |
||||||
|
|
||||||
|
.snapshot-wrap { position: relative; margin-top: 1rem; border: 1px solid rgba(255,255,255,0.14); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.02); } |
||||||
|
.snapshot-header { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; } |
||||||
|
.snapshot-meta { flex: 1 1 auto; min-width: 0; line-height: 1.65; } |
||||||
|
.snapshot-timer-box { |
||||||
|
width: 220px; |
||||||
|
min-height: 132px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
background: rgba(0,0,0,0.18); |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
justify-content:center; |
||||||
|
align-items:center; |
||||||
|
text-align:center; |
||||||
|
padding: 0.9rem; |
||||||
|
} |
||||||
|
.snapshot-timer-value { font-size: 2rem; font-weight: 800; line-height: 1.1; } |
||||||
|
.snapshot-timer-label { margin-top: 0.55rem; font-size: 0.95rem; opacity: 0.95; } |
||||||
|
.snapshot-timer-expired { color: #f87171; } |
||||||
|
|
||||||
|
.quote-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } |
||||||
|
.quote-table th, .quote-table td { padding: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; vertical-align: top; } |
||||||
|
.quote-table th { background: #e9eef7; color: #10203f; } |
||||||
|
.quote-badge { display: inline-block; padding: 0.14rem 0.48rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; margin-left: 0.4rem; } |
||||||
|
.quote-live { background: rgba(34, 197, 94, 0.18); color: #4ade80; } |
||||||
|
.quote-stale { background: rgba(239, 68, 68, 0.18); color: #f87171; } |
||||||
|
.quote-pick-btn { padding: 8px 12px; border-radius: 8px; border: none; background: #2563eb; color: #fff; font-weight: 700; cursor: pointer; } |
||||||
|
.quote-pick-btn[disabled] { opacity: 0.5; cursor: not-allowed; } |
||||||
|
|
||||||
|
.lock-box { margin-top: 1rem; border: 1px solid rgba(34, 197, 94, 0.28); background: rgba(22, 101, 52, 0.16); border-radius: 12px; padding: 1rem; } |
||||||
|
.lock-box.expired { border-color: rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); } |
||||||
|
.lock-grid { display:grid; grid-template-columns: 1fr 220px; gap:1rem; align-items:start; } |
||||||
|
.lock-code { display:block; margin-top:0.35rem; padding:0.65rem 0.8rem; background: rgba(0,0,0,0.22); border-radius: 8px; overflow-wrap:anywhere; } |
||||||
|
|
||||||
|
.wallet-actions { |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.9rem; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
.wallet-help { |
||||||
|
margin-top: 0.85rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
border: 1px solid rgba(255,255,255,0.10); |
||||||
|
} |
||||||
|
.wallet-help h4 { |
||||||
|
margin: 0 0 0.55rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
.wallet-help p { |
||||||
|
margin: 0.35rem 0; |
||||||
|
} |
||||||
|
.wallet-note { opacity:0.9; margin-top:0.65rem; } |
||||||
|
.mono { font-family: monospace; } |
||||||
|
.copy-row { |
||||||
|
display:flex; |
||||||
|
gap:0.5rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
margin-top:0.65rem; |
||||||
|
} |
||||||
|
.copy-target { |
||||||
|
flex: 1 1 420px; |
||||||
|
min-width: 220px; |
||||||
|
} |
||||||
|
.copy-status { |
||||||
|
display:inline-block; |
||||||
|
margin-left: 0.5rem; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 820px) { |
||||||
|
.snapshot-header, .lock-grid { grid-template-columns: 1fr; display:block; } |
||||||
|
.snapshot-timer-box { width: 100%; margin-top: 1rem; min-height: 110px; } |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Invoice Detail</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/dashboard">Back to Dashboard</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if (invoice.status or "")|lower == "paid" %} |
||||||
|
<div class="success-box">✓ This invoice has been paid. Thank you!</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if crypto_error %} |
||||||
|
<div class="error-box">{{ crypto_error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<div class="detail-grid"> |
||||||
|
<div class="detail-card"><h3>Invoice</h3><div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div></div> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Status</h3> |
||||||
|
{% set s = (invoice.status or "")|lower %} |
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} |
||||||
|
<span class="status-badge status-pending">processing</span> |
||||||
|
{% elif s == "paid" %}<span class="status-badge status-paid">{{ invoice.status }}</span> |
||||||
|
{% elif s == "pending" %}<span class="status-badge status-pending">{{ invoice.status }}</span> |
||||||
|
{% elif s == "overdue" %}<span class="status-badge status-overdue">{{ invoice.status }}</span> |
||||||
|
{% else %}<span class="status-badge status-other">{{ invoice.status }}</span>{% endif %} |
||||||
|
</div> |
||||||
|
<div class="detail-card"><h3>Created</h3><div>{{ invoice.created_at }}</div></div> |
||||||
|
<div class="detail-card"><h3>Total</h3><div>{{ invoice.total_amount }}</div></div> |
||||||
|
<div class="detail-card"><h3>Paid</h3><div>{{ invoice.amount_paid }}</div></div> |
||||||
|
<div class="detail-card"><h3>Outstanding</h3><div>{{ invoice.outstanding }}</div></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoice Items</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Line Total</th></tr></thead> |
||||||
|
<tbody> |
||||||
|
{% for item in items %} |
||||||
|
<tr><td>{{ item.description }}</td><td>{{ item.quantity }}</td><td>{{ item.unit_price }}</td><td>{{ item.line_total }}</td></tr> |
||||||
|
{% else %} |
||||||
|
<tr><td colspan="4">No invoice line items found.</td></tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
|
||||||
|
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %} |
||||||
|
<div class="pay-card"> |
||||||
|
<h3>Pay Now</h3> |
||||||
|
<div class="pay-selector-row"> |
||||||
|
<label for="payMethodSelect"><strong>Choose payment method:</strong></label> |
||||||
|
<select id="payMethodSelect" class="pay-selector"> |
||||||
|
<option value="" {% if not pay_mode %}selected{% endif %}>Select…</option> |
||||||
|
<option value="etransfer" {% if pay_mode == "etransfer" %}selected{% endif %}>e-Transfer</option> |
||||||
|
<option value="square" {% if pay_mode == "square" %}selected{% endif %}>Credit Card</option> |
||||||
|
<option value="crypto" {% if pay_mode == "crypto" %}selected{% endif %}>Crypto</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}"> |
||||||
|
<p><strong>Interac e-Transfer</strong><br>Send payment to:<br>payment@outsidethebox.top<br>Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}"> |
||||||
|
<p><strong>Credit Card (Square)</strong></p> |
||||||
|
<a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-square">Pay with Credit Card</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}"> |
||||||
|
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %} |
||||||
|
<div class="snapshot-wrap"> |
||||||
|
<div class="snapshot-header"> |
||||||
|
<div class="snapshot-meta"> |
||||||
|
<h3 style="margin-top:0;">Crypto Quote Snapshot</h3> |
||||||
|
<div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_at or "—" }}</div> |
||||||
|
<div><strong>Source Status:</strong> {{ invoice.oracle_quote.source_status or "—" }}</div> |
||||||
|
<div><strong>Frozen Amount:</strong> {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}</div> |
||||||
|
{% if pending_crypto_payment %} |
||||||
|
<div style="margin-top:0.75rem;"><strong>Your quote is protected after acceptance.</strong></div> |
||||||
|
{% else %} |
||||||
|
<div style="margin-top:0.75rem;"><strong>Select a crypto asset to accept the quote.</strong></div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="processingTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div> |
||||||
|
<div id="processingTimerLabel" class="snapshot-timer-label">Watching transaction / waiting for confirmation</div> |
||||||
|
</div> |
||||||
|
{% elif pending_crypto_payment %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="lockTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div> |
||||||
|
<div id="lockTimerLabel" class="snapshot-timer-label">Quote protected while you open wallet</div> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="quoteTimerValue" class="snapshot-timer-value" data-expiry="{{ crypto_quote_window_expires_iso }}">--:--</div> |
||||||
|
<div id="quoteTimerLabel" class="snapshot-timer-label">This price times out:</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if pending_crypto_payment and selected_crypto_option %} |
||||||
|
<div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired or pending_crypto_payment.processing_expired %} expired{% endif %}"> |
||||||
|
<div class="lock-grid"> |
||||||
|
<div> |
||||||
|
<h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3> |
||||||
|
<div><strong>Send exactly:</strong> {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}</div> |
||||||
|
<div style="margin-top:0.65rem;"><strong>Destination wallet:</strong></div> |
||||||
|
<code id="walletAddressText" class="lock-code copy-target">{{ pending_crypto_payment.wallet_address }}</code> |
||||||
|
<div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div> |
||||||
|
<code id="invoiceRefText" class="lock-code copy-target">{{ pending_crypto_payment.reference }}</code> |
||||||
|
|
||||||
|
{% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %} |
||||||
|
<div class="wallet-actions"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
id="walletPayButton" |
||||||
|
class="pay-btn pay-btn-wallet" |
||||||
|
data-invoice-id="{{ invoice.id }}" |
||||||
|
data-payment-id="{{ pending_crypto_payment.id }}" |
||||||
|
data-asset="{{ selected_crypto_option.symbol }}" |
||||||
|
data-chain-id="{{ selected_crypto_option.chain_id }}" |
||||||
|
data-asset-type="{{ selected_crypto_option.asset_type }}" |
||||||
|
data-to="{{ selected_crypto_option.wallet_address }}" |
||||||
|
data-amount="{{ pending_crypto_payment.payment_amount }}" |
||||||
|
data-decimals="{{ selected_crypto_option.decimals }}" |
||||||
|
data-token-contract="{{ selected_crypto_option.token_contract or '' }}" |
||||||
|
data-chain-add='{{ (selected_crypto_option.chain_add_params or {})|tojson|safe }}' |
||||||
|
> |
||||||
|
Open MetaMask / Rabby |
||||||
|
</button> |
||||||
|
|
||||||
|
<a |
||||||
|
id="metamaskMobileLink" |
||||||
|
href="#" |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
class="pay-btn pay-btn-mobile" |
||||||
|
data-invoice-id="{{ invoice.id }}" |
||||||
|
> |
||||||
|
Open in MetaMask Mobile |
||||||
|
</a> |
||||||
|
|
||||||
|
<button type="button" id="copyDetailsButton" class="pay-btn pay-btn-copy"> |
||||||
|
Copy Payment Details |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="wallet-help"> |
||||||
|
<h4>Fastest way to pay</h4> |
||||||
|
<p>1. Click <strong>Open MetaMask / Rabby</strong> if your wallet is installed in this browser.</p> |
||||||
|
<p>2. If that does not open your wallet, click <strong>Open in MetaMask Mobile</strong>.</p> |
||||||
|
<p>3. If needed, use <strong>Copy Payment Details</strong> and send manually.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="wallet-note"> |
||||||
|
You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet. |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="copy-row"> |
||||||
|
<span id="walletStatusText"></span> |
||||||
|
<span id="copyStatusText" class="copy-status"></span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% elif pending_crypto_payment.txid %} |
||||||
|
<div style="margin-top:0.9rem;"><strong>Transaction Hash:</strong></div> |
||||||
|
<code class="lock-code mono">{{ pending_crypto_payment.txid }}</code> |
||||||
|
<div style="margin-top:0.75rem;">Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.</div> |
||||||
|
{% elif pending_crypto_payment.lock_expired %} |
||||||
|
<div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto"> |
||||||
|
<table class="quote-table"> |
||||||
|
<thead><tr><th>Asset</th><th>Quoted Amount</th><th>CAD Price</th><th>Status</th><th>Action</th></tr></thead> |
||||||
|
<tbody> |
||||||
|
{% for q in crypto_options %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
{{ q.label }} |
||||||
|
{% if q.recommended %}<span class="quote-badge quote-live">recommended</span>{% endif %} |
||||||
|
{% if q.wallet_capable %}<span class="quote-badge quote-live">wallet</span>{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ q.display_amount or "—" }}</td> |
||||||
|
<td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td> |
||||||
|
<td>{% if q.available %}<span class="quote-badge quote-live">live</span>{% else %}<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>{% endif %}</td> |
||||||
|
<td><button type="submit" name="asset" value="{{ q.symbol }}" class="quote-pick-btn" {% if not q.available %}disabled{% endif %}>Accept {{ q.symbol }}</button></td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</form> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<p>No crypto quote snapshot is available for this invoice yet.</p> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if invoice_payments %} |
||||||
|
<div class="detail-card" style="margin-top:1.25rem;"> |
||||||
|
<h3>Payments Applied</h3> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Method</th> |
||||||
|
<th>Amount</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Received</th> |
||||||
|
<th>Reference / TXID</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for p in invoice_payments %} |
||||||
|
<tr> |
||||||
|
<td>{{ p.payment_method_label }}</td> |
||||||
|
<td>{{ p.payment_amount_display }} {{ p.payment_currency }}</td> |
||||||
|
<td>{{ p.payment_status }}</td> |
||||||
|
<td>{{ p.received_at_local }}</td> |
||||||
|
<td> |
||||||
|
{% if p.txid %} |
||||||
|
{{ p.txid }} |
||||||
|
{% elif p.reference %} |
||||||
|
{{ p.reference }} |
||||||
|
{% else %} |
||||||
|
- |
||||||
|
{% endif %} |
||||||
|
{% if p.wallet_address %}<br><small>{{ p.wallet_address }}</small>{% endif %} |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if pdf_url %} |
||||||
|
<div style="margin-top:1rem;"><a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a></div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
const select = document.getElementById("payMethodSelect"); |
||||||
|
if (select) { |
||||||
|
select.addEventListener("change", function() { |
||||||
|
const value = this.value || ""; |
||||||
|
const url = new URL(window.location.href); |
||||||
|
if (!value) { |
||||||
|
url.searchParams.delete("pay"); |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.delete("refresh_quote"); |
||||||
|
} else { |
||||||
|
url.searchParams.set("pay", value); |
||||||
|
if (value !== "crypto") { |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.delete("refresh_quote"); |
||||||
|
} else { |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.set("refresh_quote", "1"); |
||||||
|
} |
||||||
|
} |
||||||
|
window.location.href = url.toString(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function bindCountdown(valueId, labelId, expireIso, expiredMessage, disableSelector) { |
||||||
|
const valueEl = document.getElementById(valueId); |
||||||
|
const labelEl = document.getElementById(labelId); |
||||||
|
if (!valueEl || !expireIso) return; |
||||||
|
|
||||||
|
function tick() { |
||||||
|
const end = new Date(expireIso).getTime(); |
||||||
|
const now = Date.now(); |
||||||
|
const diff = Math.max(0, Math.floor((end - now) / 1000)); |
||||||
|
|
||||||
|
if (diff <= 0) { |
||||||
|
valueEl.textContent = "00:00"; |
||||||
|
valueEl.classList.add("snapshot-timer-expired"); |
||||||
|
if (labelEl) { |
||||||
|
labelEl.textContent = expiredMessage; |
||||||
|
labelEl.classList.add("snapshot-timer-expired"); |
||||||
|
} |
||||||
|
if (disableSelector) { |
||||||
|
document.querySelectorAll(disableSelector).forEach(btn => btn.disabled = true); |
||||||
|
} |
||||||
|
const lockBox = document.getElementById("lockBox"); |
||||||
|
if (lockBox) lockBox.classList.add("expired"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const m = String(Math.floor(diff / 60)).padStart(2, "0"); |
||||||
|
const s = String(diff % 60).padStart(2, "0"); |
||||||
|
valueEl.textContent = `${m}:${s}`; |
||||||
|
setTimeout(tick, 250); |
||||||
|
} |
||||||
|
|
||||||
|
tick(); |
||||||
|
} |
||||||
|
|
||||||
|
const quoteTimer = document.getElementById("quoteTimerValue"); |
||||||
|
if (quoteTimer && quoteTimer.dataset.expiry) { |
||||||
|
bindCountdown("quoteTimerValue", "quoteTimerLabel", quoteTimer.dataset.expiry, "price has expired - please refresh your view to update", "#cryptoPickForm button"); |
||||||
|
} |
||||||
|
|
||||||
|
const lockTimer = document.getElementById("lockTimerValue"); |
||||||
|
if (lockTimer && lockTimer.dataset.expiry) { |
||||||
|
bindCountdown("lockTimerValue", "lockTimerLabel", lockTimer.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton"); |
||||||
|
} |
||||||
|
|
||||||
|
const processingTimer = document.getElementById("processingTimerValue"); |
||||||
|
if (processingTimer && processingTimer.dataset.expiry) { |
||||||
|
bindCountdown("processingTimerValue", "processingTimerLabel", processingTimer.dataset.expiry, "price has expired - please refresh your quote to update", null); |
||||||
|
} |
||||||
|
|
||||||
|
function toHexBigIntFromDecimal(amountText, decimals) { |
||||||
|
const text = String(amountText || "0"); |
||||||
|
const parts = text.split("."); |
||||||
|
const whole = parts[0] || "0"; |
||||||
|
const frac = (parts[1] || "").padEnd(decimals, "0").slice(0, decimals); |
||||||
|
const combined = (whole + frac).replace(/^0+/, "") || "0"; |
||||||
|
return "0x" + BigInt(combined).toString(16); |
||||||
|
} |
||||||
|
|
||||||
|
function erc20TransferData(to, amountText, decimals) { |
||||||
|
const method = "a9059cbb"; |
||||||
|
const addr = String(to || "").toLowerCase().replace(/^0x/, "").padStart(64, "0"); |
||||||
|
const amtHex = BigInt(toHexBigIntFromDecimal(amountText, decimals)).toString(16).padStart(64, "0"); |
||||||
|
return "0x" + method + addr + amtHex; |
||||||
|
} |
||||||
|
|
||||||
|
async function switchChain(chainId, chainAddParams) { |
||||||
|
const hexChainId = "0x" + Number(chainId).toString(16); |
||||||
|
try { |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_switchEthereumChain", |
||||||
|
params: [{ chainId: hexChainId }] |
||||||
|
}); |
||||||
|
return; |
||||||
|
} catch (err) { |
||||||
|
const code = err && (err.code ?? err?.data?.originalError?.code); |
||||||
|
if ((code === 4902 || String(err).includes("4902")) && chainAddParams) { |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_addEthereumChain", |
||||||
|
params: [chainAddParams] |
||||||
|
}); |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_switchEthereumChain", |
||||||
|
params: [{ chainId: hexChainId }] |
||||||
|
}); |
||||||
|
return; |
||||||
|
} |
||||||
|
throw err; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function buildMetaMaskMobileLink() { |
||||||
|
const currentUrl = window.location.href; |
||||||
|
return "https://link.metamask.io/dapp/" + currentUrl.replace(/^https?:\/\//, ""); |
||||||
|
} |
||||||
|
|
||||||
|
const mmLink = document.getElementById("metamaskMobileLink"); |
||||||
|
if (mmLink) { |
||||||
|
mmLink.href = buildMetaMaskMobileLink(); |
||||||
|
} |
||||||
|
|
||||||
|
async function copyText(text) { |
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) { |
||||||
|
await navigator.clipboard.writeText(text); |
||||||
|
return; |
||||||
|
} |
||||||
|
const ta = document.createElement("textarea"); |
||||||
|
ta.value = text; |
||||||
|
document.body.appendChild(ta); |
||||||
|
ta.select(); |
||||||
|
document.execCommand("copy"); |
||||||
|
ta.remove(); |
||||||
|
} |
||||||
|
|
||||||
|
const copyBtn = document.getElementById("copyDetailsButton"); |
||||||
|
if (copyBtn) { |
||||||
|
copyBtn.addEventListener("click", async function() { |
||||||
|
const copyStatus = document.getElementById("copyStatusText"); |
||||||
|
const walletAddress = document.getElementById("walletAddressText")?.textContent || ""; |
||||||
|
const invoiceRef = document.getElementById("invoiceRefText")?.textContent || ""; |
||||||
|
const amount = document.getElementById("walletPayButton")?.dataset.amount || ""; |
||||||
|
const asset = document.getElementById("walletPayButton")?.dataset.asset || ""; |
||||||
|
|
||||||
|
const payload = |
||||||
|
`Asset: ${asset} |
||||||
|
Amount: ${amount} ${asset} |
||||||
|
Wallet: ${walletAddress} |
||||||
|
Reference: ${invoiceRef}`; |
||||||
|
|
||||||
|
try { |
||||||
|
await copyText(payload); |
||||||
|
if (copyStatus) copyStatus.textContent = "Payment details copied."; |
||||||
|
} catch (err) { |
||||||
|
if (copyStatus) copyStatus.textContent = "Copy failed."; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const walletButton = document.getElementById("walletPayButton"); |
||||||
|
|
||||||
|
function pendingTxStorageKey(invoiceId, paymentId) { |
||||||
|
return `otb_pending_tx_${invoiceId}_${paymentId}`; |
||||||
|
} |
||||||
|
|
||||||
|
async function submitTxHash(invoiceId, paymentId, asset, txHash) { |
||||||
|
const res = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { |
||||||
|
method: "POST", |
||||||
|
headers: { |
||||||
|
"Content-Type": "application/json", |
||||||
|
"Accept": "application/json" |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
payment_id: paymentId, |
||||||
|
asset: asset, |
||||||
|
tx_hash: txHash |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
let data = {}; |
||||||
|
try { |
||||||
|
data = await res.json(); |
||||||
|
} catch (err) { |
||||||
|
data = { ok: false, error: "invalid_json_response" }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!res.ok || !data.ok) { |
||||||
|
throw new Error(data.error || `submit_failed_http_${res.status}`); |
||||||
|
} |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
async function tryRecoverPendingTxFromStorage() { |
||||||
|
const invoiceId = "{{ invoice.id }}"; |
||||||
|
const paymentId = "{{ pending_crypto_payment.id if pending_crypto_payment else '' }}"; |
||||||
|
const asset = "{{ selected_crypto_option.symbol if selected_crypto_option else '' }}"; |
||||||
|
|
||||||
|
if (!invoiceId || !paymentId || !asset) return; |
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||||
|
return; |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
const key = pendingTxStorageKey(invoiceId, paymentId); |
||||||
|
const savedTx = localStorage.getItem(key); |
||||||
|
if (!savedTx || !savedTx.startsWith("0x")) return; |
||||||
|
|
||||||
|
const walletStatus = document.getElementById("walletStatusText"); |
||||||
|
try { |
||||||
|
if (walletStatus) walletStatus.textContent = "Retrying saved transaction submission..."; |
||||||
|
await submitTxHash(invoiceId, paymentId, asset, savedTx); |
||||||
|
localStorage.removeItem(key); |
||||||
|
const url = new URL(window.location.href); |
||||||
|
url.searchParams.set("pay", "crypto"); |
||||||
|
url.searchParams.set("asset", asset); |
||||||
|
url.searchParams.set("payment_id", paymentId); |
||||||
|
window.location.href = url.toString(); |
||||||
|
} catch (err) { |
||||||
|
if (walletStatus) walletStatus.textContent = `Saved tx retry failed: ${err.message}`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (walletButton) { |
||||||
|
walletButton.addEventListener("click", async function() { |
||||||
|
const walletStatus = document.getElementById("walletStatusText"); |
||||||
|
const invoiceId = this.dataset.invoiceId; |
||||||
|
const paymentId = this.dataset.paymentId; |
||||||
|
const asset = this.dataset.asset; |
||||||
|
const chainId = this.dataset.chainId; |
||||||
|
const assetType = this.dataset.assetType; |
||||||
|
const to = this.dataset.to; |
||||||
|
const amount = this.dataset.amount; |
||||||
|
const decimals = Number(this.dataset.decimals || "18"); |
||||||
|
const tokenContract = this.dataset.tokenContract || ""; |
||||||
|
let chainAddParams = null; |
||||||
|
try { |
||||||
|
chainAddParams = this.dataset.chainAdd ? JSON.parse(this.dataset.chainAdd) : null; |
||||||
|
} catch (err) { |
||||||
|
chainAddParams = null; |
||||||
|
} |
||||||
|
|
||||||
|
const setStatus = (msg) => { |
||||||
|
if (walletStatus) walletStatus.textContent = msg; |
||||||
|
}; |
||||||
|
|
||||||
|
if (!window.ethereum || !window.ethereum.request) { |
||||||
|
setStatus("No browser wallet detected. Use MetaMask/Rabby or MetaMask Mobile."); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
this.disabled = true; |
||||||
|
setStatus("Opening wallet..."); |
||||||
|
|
||||||
|
await window.ethereum.request({ method: "eth_requestAccounts" }); |
||||||
|
|
||||||
|
if (chainId && chainId !== "None" && chainId !== "") { |
||||||
|
try { |
||||||
|
await switchChain(Number(chainId), chainAddParams); |
||||||
|
} catch (err) { |
||||||
|
setStatus(`Chain switch failed: ${err.message || err}`); |
||||||
|
this.disabled = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let txParams; |
||||||
|
if (assetType === "token" && tokenContract) { |
||||||
|
txParams = { |
||||||
|
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||||
|
to: tokenContract, |
||||||
|
data: erc20TransferData(to, amount, decimals), |
||||||
|
value: "0x0" |
||||||
|
}; |
||||||
|
} else { |
||||||
|
txParams = { |
||||||
|
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||||
|
to: to, |
||||||
|
value: toHexBigIntFromDecimal(amount, decimals) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
setStatus("Waiting for wallet confirmation..."); |
||||||
|
const txHash = await window.ethereum.request({ |
||||||
|
method: "eth_sendTransaction", |
||||||
|
params: [txParams] |
||||||
|
}); |
||||||
|
|
||||||
|
if (!txHash || !String(txHash).startsWith("0x")) { |
||||||
|
throw new Error("wallet did not return a tx hash"); |
||||||
|
} |
||||||
|
|
||||||
|
const storageKey = pendingTxStorageKey(invoiceId, paymentId); |
||||||
|
localStorage.setItem(storageKey, String(txHash)); |
||||||
|
|
||||||
|
setStatus(`Wallet submitted tx: ${txHash}. Sending to billing server...`); |
||||||
|
|
||||||
|
await submitTxHash(invoiceId, paymentId, asset, txHash); |
||||||
|
|
||||||
|
localStorage.removeItem(storageKey); |
||||||
|
|
||||||
|
setStatus("Transaction submitted. Reloading into processing view..."); |
||||||
|
const url = new URL(window.location.href); |
||||||
|
url.searchParams.set("pay", "crypto"); |
||||||
|
url.searchParams.set("asset", asset); |
||||||
|
url.searchParams.set("payment_id", paymentId); |
||||||
|
window.location.href = url.toString(); |
||||||
|
} catch (err) { |
||||||
|
setStatus(`Wallet submit failed: ${err.message || err}`); |
||||||
|
this.disabled = false; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
tryRecoverPendingTxFromStorage(); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
const processingAutoRefreshEnabled = {{ 'true' if pending_crypto_payment and pending_crypto_payment.txid and (invoice.status or '')|lower != 'paid' else 'false' }}; |
||||||
|
if (processingAutoRefreshEnabled) { |
||||||
|
setTimeout(function() { |
||||||
|
window.location.reload(); |
||||||
|
}, 10000); |
||||||
|
} |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,104 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-note { margin-top: 1rem; opacity: 0.88; font-size: 0.95rem; } |
||||||
|
.portal-links { margin-top: 1rem; } |
||||||
|
.portal-links a { margin-right: 1rem; } |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="https://outsidethebox.top/">Home</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,84 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Set Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Create Your Portal Password</h1> |
||||||
|
<p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/set-password"> |
||||||
|
<div> |
||||||
|
<label for="password">New Password</label> |
||||||
|
<input id="password" name="password" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label for="password2">Confirm Password</label> |
||||||
|
<input id="password2" name="password2" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<button class="portal-btn" type="submit">Set Password</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<header class="header"> |
||||||
|
<div class="container"> |
||||||
|
<div class="nav"> |
||||||
|
<a class="brand" href="https://outsidethebox.top"> |
||||||
|
<img src="/static/favicon.png" alt="outsidethebox.top logo" /> |
||||||
|
<div class="title"> |
||||||
|
<strong>outsidethebox.top</strong> |
||||||
|
<span>Managed hosting • no client server logins</span> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
|
||||||
|
<nav class="navlinks"> |
||||||
|
<a href="https://outsidethebox.top">Home</a> |
||||||
|
<a href="https://outsidethebox.top/pricing.html">Pricing</a> |
||||||
|
<a href="https://outsidethebox.top/terms.html">ToS</a> |
||||||
|
<a href="https://outsidethebox.top/contact.html">Contact</a> |
||||||
|
<a href="/portal">Portal</a> |
||||||
|
</nav> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</header> |
||||||
@ -0,0 +1,699 @@ |
|||||||
|
:root{ |
||||||
|
--bg:#0b0f14; |
||||||
|
--card:#121825; |
||||||
|
--card-soft:rgba(18,24,37,.78); |
||||||
|
--text:#e8eefc; |
||||||
|
--muted:#aab6d6; |
||||||
|
--line:#24304a; |
||||||
|
--accent:#7aa2ff; |
||||||
|
--accent2:#62e6b7; |
||||||
|
--success:#4ade80; |
||||||
|
--warn:#fbbf24; |
||||||
|
--danger:#f87171; |
||||||
|
--radius:16px; |
||||||
|
--shadow:0 16px 40px rgba(0,0,0,.35); |
||||||
|
} |
||||||
|
|
||||||
|
*{box-sizing:border-box} |
||||||
|
html,body{height:100%} |
||||||
|
|
||||||
|
body{ |
||||||
|
margin:0; |
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji","Segoe UI Emoji"; |
||||||
|
background: |
||||||
|
radial-gradient(1200px 600px at 20% 0%, rgba(122,162,255,.22), transparent 60%), |
||||||
|
radial-gradient(900px 500px at 90% 20%, rgba(98,230,183,.15), transparent 60%), |
||||||
|
linear-gradient(180deg, #081225 0%, #09172d 100%); |
||||||
|
color:var(--text); |
||||||
|
line-height:1.45; |
||||||
|
} |
||||||
|
|
||||||
|
a{color:inherit} |
||||||
|
|
||||||
|
/* ===== Shared container ===== */ |
||||||
|
.container{ |
||||||
|
max-width:1100px; |
||||||
|
margin:0 auto; |
||||||
|
padding:20px 18px; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Header / branded nav ===== */ |
||||||
|
.header{width:100%} |
||||||
|
|
||||||
|
.nav{ |
||||||
|
display:flex; |
||||||
|
align-items:center; |
||||||
|
justify-content:space-between; |
||||||
|
gap:18px; |
||||||
|
padding:12px 0 22px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.brand{ |
||||||
|
display:flex; |
||||||
|
align-items:center; |
||||||
|
gap:14px; |
||||||
|
text-decoration:none; |
||||||
|
} |
||||||
|
|
||||||
|
.brand img{ |
||||||
|
height:60px; |
||||||
|
width:auto; |
||||||
|
display:block; |
||||||
|
object-fit:contain; |
||||||
|
background: rgba(255,255,255,0.92); |
||||||
|
padding: 6px 12px; |
||||||
|
border-radius: 999px; |
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.35); |
||||||
|
} |
||||||
|
|
||||||
|
.title{ |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
line-height:1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.title strong{letter-spacing:.2px} |
||||||
|
.title span{ |
||||||
|
color:var(--muted); |
||||||
|
font-size:13px; |
||||||
|
margin-top:2px; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks{ |
||||||
|
display:flex; |
||||||
|
gap:12px; |
||||||
|
flex-wrap:wrap; |
||||||
|
justify-content:flex-end; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a{ |
||||||
|
text-decoration:none; |
||||||
|
padding:8px 10px; |
||||||
|
border-radius:12px; |
||||||
|
color:var(--muted); |
||||||
|
border:1px solid transparent; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a:hover{ |
||||||
|
color:var(--text); |
||||||
|
border-color:rgba(255,255,255,.08); |
||||||
|
background:rgba(255,255,255,.03); |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a.active{ |
||||||
|
color:var(--text); |
||||||
|
border-color:rgba(255,255,255,.10); |
||||||
|
background:rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Generic portal shell ===== */ |
||||||
|
.portal-shell{ |
||||||
|
max-width:1100px; |
||||||
|
margin:16px auto 28px auto; |
||||||
|
padding:0 18px 20px 18px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-card, |
||||||
|
.detail-card, |
||||||
|
.summary-card, |
||||||
|
.pay-card{ |
||||||
|
background: var(--card-soft); |
||||||
|
border: 1px solid rgba(255,255,255,.07); |
||||||
|
border-radius: var(--radius); |
||||||
|
box-shadow: var(--shadow); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-card{ |
||||||
|
max-width:760px; |
||||||
|
margin:24px auto 12px auto; |
||||||
|
padding:22px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-header{ |
||||||
|
display:flex; |
||||||
|
align-items:flex-start; |
||||||
|
justify-content:space-between; |
||||||
|
gap:16px; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin:6px 0 18px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-title{ |
||||||
|
margin:0; |
||||||
|
font-size:24px; |
||||||
|
line-height:1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-subtitle{ |
||||||
|
margin:8px 0 0 0; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-client-name{ |
||||||
|
margin:8px 0 0 0; |
||||||
|
color:var(--text); |
||||||
|
font-size:15px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-toolbar{ |
||||||
|
display:flex; |
||||||
|
gap:10px; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn, |
||||||
|
.btn, |
||||||
|
.pay-btn, |
||||||
|
.quote-pick-btn{ |
||||||
|
display:inline-flex; |
||||||
|
align-items:center; |
||||||
|
justify-content:center; |
||||||
|
gap:8px; |
||||||
|
min-height:42px; |
||||||
|
padding:10px 14px; |
||||||
|
border-radius:12px; |
||||||
|
text-decoration:none; |
||||||
|
border:1px solid rgba(255,255,255,.10); |
||||||
|
background: rgba(255,255,255,.05); |
||||||
|
color:var(--text); |
||||||
|
font-weight:600; |
||||||
|
cursor:pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn:hover, |
||||||
|
.btn:hover, |
||||||
|
.pay-btn:hover, |
||||||
|
.quote-pick-btn:hover{ |
||||||
|
border-color:rgba(122,162,255,.45); |
||||||
|
box-shadow:0 0 0 4px rgba(122,162,255,.12); |
||||||
|
text-decoration:none; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn.primary, |
||||||
|
.btn.primary{ |
||||||
|
background: linear-gradient(135deg, rgba(122,162,255,.95), rgba(98,230,183,.85)); |
||||||
|
border-color: transparent; |
||||||
|
color:#071017; |
||||||
|
font-weight:700; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn.primary:hover, |
||||||
|
.btn.primary:hover{ |
||||||
|
box-shadow:0 0 0 4px rgba(98,230,183,.18); |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Login / forms ===== */ |
||||||
|
.portal-sub{ |
||||||
|
color:var(--muted); |
||||||
|
margin:0 0 16px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form{ |
||||||
|
display:grid; |
||||||
|
gap:14px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form label{ |
||||||
|
display:block; |
||||||
|
font-weight:600; |
||||||
|
margin-bottom:6px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form input, |
||||||
|
.portal-form select, |
||||||
|
.pay-selector{ |
||||||
|
width:100%; |
||||||
|
padding:12px 14px; |
||||||
|
border-radius:12px; |
||||||
|
border:1px solid rgba(255,255,255,.14); |
||||||
|
background: rgba(255,255,255,.06); |
||||||
|
color:var(--text); |
||||||
|
box-sizing:border-box; |
||||||
|
outline:none; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form input:focus, |
||||||
|
.portal-form select:focus, |
||||||
|
.pay-selector:focus{ |
||||||
|
border-color:rgba(122,162,255,.65); |
||||||
|
box-shadow:0 0 0 4px rgba(122,162,255,.12); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-actions{ |
||||||
|
display:flex; |
||||||
|
gap:10px; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:4px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-note{ |
||||||
|
margin-top:16px; |
||||||
|
color:var(--muted); |
||||||
|
font-size:14px; |
||||||
|
line-height:1.5; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-msg, |
||||||
|
.error-box, |
||||||
|
.success-box{ |
||||||
|
margin-bottom:16px; |
||||||
|
padding:12px 14px; |
||||||
|
border-radius:12px; |
||||||
|
border:1px solid rgba(255,255,255,.16); |
||||||
|
background: rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
.error-box{ |
||||||
|
border-color: rgba(239, 68, 68, 0.55); |
||||||
|
background: rgba(127, 29, 29, 0.22); |
||||||
|
color: #fecaca; |
||||||
|
} |
||||||
|
|
||||||
|
.success-box{ |
||||||
|
border-color: rgba(34, 197, 94, 0.55); |
||||||
|
background: rgba(22, 101, 52, 0.18); |
||||||
|
color: #dcfce7; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Dashboard ===== */ |
||||||
|
.portal-wrap{ |
||||||
|
max-width:1100px; |
||||||
|
margin:0 auto; |
||||||
|
padding:0; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-grid, |
||||||
|
.detail-grid{ |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)); |
||||||
|
gap:14px; |
||||||
|
margin: 0 0 18px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card, |
||||||
|
.detail-card{ |
||||||
|
padding:18px; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card h3, |
||||||
|
.detail-card h3{ |
||||||
|
margin:0 0 8px 0; |
||||||
|
font-size:14px; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card .summary-value, |
||||||
|
.detail-card .detail-value{ |
||||||
|
font-size:28px; |
||||||
|
font-weight:800; |
||||||
|
line-height:1.1; |
||||||
|
color:var(--text); |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card .summary-sub{ |
||||||
|
margin-top:6px; |
||||||
|
font-size:12px; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.section-title{ |
||||||
|
margin:18px 0 10px 0; |
||||||
|
font-size:22px; |
||||||
|
} |
||||||
|
|
||||||
|
.table-card{ |
||||||
|
background: var(--card-soft); |
||||||
|
border: 1px solid rgba(255,255,255,.07); |
||||||
|
border-radius: var(--radius); |
||||||
|
box-shadow: var(--shadow); |
||||||
|
overflow:hidden; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Tables ===== */ |
||||||
|
table, |
||||||
|
.portal-table, |
||||||
|
.quote-table{ |
||||||
|
width:100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table th, |
||||||
|
table.portal-table td, |
||||||
|
.quote-table th, |
||||||
|
.quote-table td{ |
||||||
|
padding: 0.9rem 0.85rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.10); |
||||||
|
text-align: left; |
||||||
|
vertical-align: middle; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table th, |
||||||
|
.quote-table th{ |
||||||
|
background: rgba(255,255,255,.06); |
||||||
|
color: var(--text); |
||||||
|
font-size:13px; |
||||||
|
letter-spacing:.02em; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table tr:hover td, |
||||||
|
.quote-table tr:hover td{ |
||||||
|
background: rgba(255,255,255,.02); |
||||||
|
} |
||||||
|
|
||||||
|
.invoice-link{ |
||||||
|
color: var(--text); |
||||||
|
text-decoration: none; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.invoice-link:hover{ |
||||||
|
color: var(--accent); |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Badges ===== */ |
||||||
|
.status-badge, |
||||||
|
.quote-badge{ |
||||||
|
display: inline-block; |
||||||
|
padding: 0.22rem 0.62rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.82rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.status-paid{ background: rgba(34, 197, 94, 0.18); color: var(--success); } |
||||||
|
.status-pending{ background: rgba(245, 158, 11, 0.20); color: var(--warn); } |
||||||
|
.status-overdue{ background: rgba(239, 68, 68, 0.18); color: var(--danger); } |
||||||
|
.status-other{ background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } |
||||||
|
|
||||||
|
.quote-live{ background: rgba(34, 197, 94, 0.18); color: var(--success); } |
||||||
|
.quote-stale{ background: rgba(239, 68, 68, 0.18); color: var(--danger); } |
||||||
|
|
||||||
|
/* ===== Payments / invoice detail ===== */ |
||||||
|
.pay-card{ |
||||||
|
padding:18px; |
||||||
|
margin-top: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pay-selector-row{ |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
align-items:center; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pay-panel{ |
||||||
|
margin-top: 1rem; |
||||||
|
padding: 1rem; |
||||||
|
border: 1px solid rgba(255,255,255,0.12); |
||||||
|
border-radius: 12px; |
||||||
|
background: rgba(255,255,255,0.02); |
||||||
|
} |
||||||
|
|
||||||
|
.pay-panel.hidden{ display:none; } |
||||||
|
|
||||||
|
.pay-btn-square { background:#16a34a; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-wallet { background:#2563eb; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-mobile { background:#7c3aed; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-copy { background:#374151; border-color:transparent; color:#fff; } |
||||||
|
|
||||||
|
.snapshot-wrap{ |
||||||
|
position: relative; |
||||||
|
margin-top: 1rem; |
||||||
|
border: 1px solid rgba(255,255,255,0.14); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.02); |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-header{ |
||||||
|
display:flex; |
||||||
|
justify-content:space-between; |
||||||
|
gap:1rem; |
||||||
|
align-items:flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-meta{ |
||||||
|
flex: 1 1 auto; |
||||||
|
min-width: 0; |
||||||
|
line-height: 1.65; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-box{ |
||||||
|
width: 220px; |
||||||
|
min-height: 132px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
background: rgba(0,0,0,0.18); |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
justify-content:center; |
||||||
|
align-items:center; |
||||||
|
text-align:center; |
||||||
|
padding: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-value{ |
||||||
|
font-size: 2rem; |
||||||
|
font-weight: 800; |
||||||
|
line-height: 1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-label{ |
||||||
|
margin-top: 0.55rem; |
||||||
|
font-size: 0.95rem; |
||||||
|
opacity: 0.95; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-expired{ color: var(--danger); } |
||||||
|
|
||||||
|
.lock-box{ |
||||||
|
margin-top: 1rem; |
||||||
|
border: 1px solid rgba(34, 197, 94, 0.28); |
||||||
|
background: rgba(22, 101, 52, 0.16); |
||||||
|
border-radius: 12px; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.lock-box.expired{ |
||||||
|
border-color: rgba(239, 68, 68, 0.55); |
||||||
|
background: rgba(127, 29, 29, 0.22); |
||||||
|
} |
||||||
|
|
||||||
|
.lock-grid{ |
||||||
|
display:grid; |
||||||
|
grid-template-columns: 1fr 220px; |
||||||
|
gap:1rem; |
||||||
|
align-items:start; |
||||||
|
} |
||||||
|
|
||||||
|
.lock-code{ |
||||||
|
display:block; |
||||||
|
margin-top:0.35rem; |
||||||
|
padding:0.65rem 0.8rem; |
||||||
|
background: rgba(0,0,0,0.22); |
||||||
|
border-radius: 8px; |
||||||
|
overflow-wrap:anywhere; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-actions{ |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.9rem; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help{ |
||||||
|
margin-top: 0.85rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
border: 1px solid rgba(255,255,255,0.10); |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help h4{ |
||||||
|
margin: 0 0 0.55rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help p{ margin: 0.35rem 0; } |
||||||
|
.wallet-note{ opacity:0.9; margin-top:0.65rem; } |
||||||
|
.mono{ font-family: monospace; } |
||||||
|
|
||||||
|
.copy-row{ |
||||||
|
display:flex; |
||||||
|
gap:0.5rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
margin-top:0.65rem; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-target{ |
||||||
|
flex: 1 1 420px; |
||||||
|
min-width: 220px; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-status{ |
||||||
|
display:inline-block; |
||||||
|
margin-left: 0.5rem; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Footer ===== */ |
||||||
|
footer{ |
||||||
|
margin:28px auto 20px auto; |
||||||
|
max-width:1100px; |
||||||
|
padding:0 18px; |
||||||
|
color:var(--muted); |
||||||
|
font-size:13px; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Responsive ===== */ |
||||||
|
@media (max-width: 900px){ |
||||||
|
.summary-grid, |
||||||
|
.detail-grid{ |
||||||
|
grid-template-columns:1fr; |
||||||
|
} |
||||||
|
|
||||||
|
.nav{ |
||||||
|
align-items:flex-start; |
||||||
|
flex-direction:column; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks{ |
||||||
|
justify-content:flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.brand img{ |
||||||
|
height:54px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 820px){ |
||||||
|
.snapshot-header, |
||||||
|
.lock-grid{ |
||||||
|
grid-template-columns: 1fr; |
||||||
|
display:block; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-box{ |
||||||
|
width: 100%; |
||||||
|
margin-top: 1rem; |
||||||
|
min-height: 110px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/* ===== Fixed CAD / Oracle status bar ===== */ |
||||||
|
body{ |
||||||
|
padding-bottom: 56px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar{ |
||||||
|
position: fixed; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
z-index: 9999; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
min-height: 42px; |
||||||
|
padding: 8px 14px; |
||||||
|
background: rgba(8, 16, 32, 0.94); |
||||||
|
border-top: 1px solid rgba(255,255,255,.10); |
||||||
|
backdrop-filter: blur(8px); |
||||||
|
box-shadow: 0 -8px 24px rgba(0,0,0,.28); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar-inner{ |
||||||
|
width: 100%; |
||||||
|
max-width: 1100px; |
||||||
|
display: flex; |
||||||
|
gap: 10px; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
flex-wrap: wrap; |
||||||
|
text-align: center; |
||||||
|
color: var(--muted); |
||||||
|
font-size: 12px; |
||||||
|
line-height: 1.35; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar strong{ |
||||||
|
color: var(--text); |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar a{ |
||||||
|
color: var(--accent2); |
||||||
|
text-decoration: none; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar a:hover{ |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-dot{ |
||||||
|
width: 6px; |
||||||
|
height: 6px; |
||||||
|
border-radius: 999px; |
||||||
|
display: inline-block; |
||||||
|
background: rgba(255,255,255,.25); |
||||||
|
flex: 0 0 auto; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Payment method chips ===== */ |
||||||
|
.payment-method{ |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
gap: 7px; |
||||||
|
margin-top: 7px; |
||||||
|
padding: 4px 9px; |
||||||
|
border-radius: 999px; |
||||||
|
background: rgba(255,255,255,.05); |
||||||
|
border: 1px solid rgba(255,255,255,.08); |
||||||
|
color: var(--muted); |
||||||
|
font-size: 11px; |
||||||
|
font-weight: 700; |
||||||
|
letter-spacing: .01em; |
||||||
|
} |
||||||
|
|
||||||
|
.payment-method::before{ |
||||||
|
content: ""; |
||||||
|
width: 8px; |
||||||
|
height: 8px; |
||||||
|
border-radius: 999px; |
||||||
|
display: inline-block; |
||||||
|
background: #7aa2ff; |
||||||
|
box-shadow: 0 0 0 3px rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
.payment-square::before{ background: #7dd3fc; } |
||||||
|
.payment-etransfer::before{ background: #86efac; } |
||||||
|
.payment-etho::before{ background: #b084ff; } |
||||||
|
.payment-etica::before{ background: #4fd1c5; } |
||||||
|
.payment-alt::before{ background: #fbbf24; } |
||||||
|
.payment-cad::before{ background: #cbd5e1; } |
||||||
|
|
||||||
|
/* ===== Slightly stronger row hover ===== */ |
||||||
|
table.portal-table tbody tr:hover td, |
||||||
|
.quote-table tbody tr:hover td{ |
||||||
|
background: rgba(255,255,255,.035); |
||||||
|
transition: background .14s ease; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 700px){ |
||||||
|
body{ |
||||||
|
padding-bottom: 72px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar-inner{ |
||||||
|
font-size: 11px; |
||||||
|
line-height: 1.25; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
<header class="header"> |
||||||
|
<div class="container"> |
||||||
|
<div class="nav"> |
||||||
|
<a class="brand" href="https://outsidethebox.top"> |
||||||
|
<img src="/static/favicon.png" alt="outsidethebox.top logo" /> |
||||||
|
<div class="title"> |
||||||
|
<strong>outsidethebox.top</strong> |
||||||
|
<span>Managed hosting • no client server logins</span> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
|
||||||
|
<nav class="navlinks"> |
||||||
|
<a href="https://outsidethebox.top">Home</a> |
||||||
|
<a href="https://outsidethebox.top/pricing.html">Pricing</a> |
||||||
|
<a href="https://outsidethebox.top/terms.html">ToS</a> |
||||||
|
<a href="https://outsidethebox.top/contact.html">Contact</a> |
||||||
|
<div class="dropdown"> |
||||||
|
<a href="#" class="dropdown-toggle">Services</a> |
||||||
|
<div class="dropdown-menu"> |
||||||
|
<a href="https://follow-me.outsidethebox.top">Follow-me Tracker</a> |
||||||
|
<a href="https://monitor.outsidethebox.top">Oracle</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<a href="/portal">Portal</a> |
||||||
|
</nav> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</header> |
||||||
@ -0,0 +1,775 @@ |
|||||||
|
:root{ |
||||||
|
--bg:#0b0f14; |
||||||
|
--card:#121825; |
||||||
|
--card-soft:rgba(18,24,37,.78); |
||||||
|
--text:#e8eefc; |
||||||
|
--muted:#aab6d6; |
||||||
|
--line:#24304a; |
||||||
|
--accent:#7aa2ff; |
||||||
|
--accent2:#62e6b7; |
||||||
|
--success:#4ade80; |
||||||
|
--warn:#fbbf24; |
||||||
|
--danger:#f87171; |
||||||
|
--radius:16px; |
||||||
|
--shadow:0 16px 40px rgba(0,0,0,.35); |
||||||
|
} |
||||||
|
|
||||||
|
*{box-sizing:border-box} |
||||||
|
html,body{height:100%} |
||||||
|
|
||||||
|
body{ |
||||||
|
margin:0; |
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji","Segoe UI Emoji"; |
||||||
|
background: |
||||||
|
radial-gradient(1200px 600px at 20% 0%, rgba(122,162,255,.22), transparent 60%), |
||||||
|
radial-gradient(900px 500px at 90% 20%, rgba(98,230,183,.15), transparent 60%), |
||||||
|
linear-gradient(180deg, #081225 0%, #09172d 100%); |
||||||
|
color:var(--text); |
||||||
|
line-height:1.45; |
||||||
|
} |
||||||
|
|
||||||
|
a{color:inherit} |
||||||
|
|
||||||
|
/* ===== Shared container ===== */ |
||||||
|
.container{ |
||||||
|
max-width:1100px; |
||||||
|
margin:0 auto; |
||||||
|
padding:20px 18px; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Header / branded nav ===== */ |
||||||
|
.header{width:100%} |
||||||
|
|
||||||
|
.nav{ |
||||||
|
display:flex; |
||||||
|
align-items:center; |
||||||
|
justify-content:space-between; |
||||||
|
gap:18px; |
||||||
|
padding:12px 0 22px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.brand{ |
||||||
|
display:flex; |
||||||
|
align-items:center; |
||||||
|
gap:14px; |
||||||
|
text-decoration:none; |
||||||
|
} |
||||||
|
|
||||||
|
.brand img{ |
||||||
|
height:60px; |
||||||
|
width:auto; |
||||||
|
display:block; |
||||||
|
object-fit:contain; |
||||||
|
background: rgba(255,255,255,0.92); |
||||||
|
padding: 6px 12px; |
||||||
|
border-radius: 999px; |
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.35); |
||||||
|
} |
||||||
|
|
||||||
|
.title{ |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
line-height:1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.title strong{letter-spacing:.2px} |
||||||
|
.title span{ |
||||||
|
color:var(--muted); |
||||||
|
font-size:13px; |
||||||
|
margin-top:2px; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks{ |
||||||
|
display:flex; |
||||||
|
gap:12px; |
||||||
|
flex-wrap:wrap; |
||||||
|
justify-content:flex-end; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a{ |
||||||
|
text-decoration:none; |
||||||
|
padding:8px 10px; |
||||||
|
border-radius:12px; |
||||||
|
color:var(--muted); |
||||||
|
border:1px solid transparent; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a:hover{ |
||||||
|
color:var(--text); |
||||||
|
border-color:rgba(255,255,255,.08); |
||||||
|
background:rgba(255,255,255,.03); |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a.active{ |
||||||
|
color:var(--text); |
||||||
|
border-color:rgba(255,255,255,.10); |
||||||
|
background:rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Generic portal shell ===== */ |
||||||
|
.portal-shell{ |
||||||
|
max-width:1100px; |
||||||
|
margin:16px auto 28px auto; |
||||||
|
padding:0 18px 20px 18px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-card, |
||||||
|
.detail-card, |
||||||
|
.summary-card, |
||||||
|
.pay-card{ |
||||||
|
background: var(--card-soft); |
||||||
|
border: 1px solid rgba(255,255,255,.07); |
||||||
|
border-radius: var(--radius); |
||||||
|
box-shadow: var(--shadow); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-card{ |
||||||
|
max-width:760px; |
||||||
|
margin:24px auto 12px auto; |
||||||
|
padding:22px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-header{ |
||||||
|
display:flex; |
||||||
|
align-items:flex-start; |
||||||
|
justify-content:space-between; |
||||||
|
gap:16px; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin:6px 0 18px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-title{ |
||||||
|
margin:0; |
||||||
|
font-size:24px; |
||||||
|
line-height:1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-subtitle{ |
||||||
|
margin:8px 0 0 0; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-client-name{ |
||||||
|
margin:8px 0 0 0; |
||||||
|
color:var(--text); |
||||||
|
font-size:15px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-toolbar{ |
||||||
|
display:flex; |
||||||
|
gap:10px; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn, |
||||||
|
.btn, |
||||||
|
.pay-btn, |
||||||
|
.quote-pick-btn{ |
||||||
|
display:inline-flex; |
||||||
|
align-items:center; |
||||||
|
justify-content:center; |
||||||
|
gap:8px; |
||||||
|
min-height:42px; |
||||||
|
padding:10px 14px; |
||||||
|
border-radius:12px; |
||||||
|
text-decoration:none; |
||||||
|
border:1px solid rgba(255,255,255,.10); |
||||||
|
background: rgba(255,255,255,.05); |
||||||
|
color:var(--text); |
||||||
|
font-weight:600; |
||||||
|
cursor:pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn:hover, |
||||||
|
.btn:hover, |
||||||
|
.pay-btn:hover, |
||||||
|
.quote-pick-btn:hover{ |
||||||
|
border-color:rgba(122,162,255,.45); |
||||||
|
box-shadow:0 0 0 4px rgba(122,162,255,.12); |
||||||
|
text-decoration:none; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn.primary, |
||||||
|
.btn.primary{ |
||||||
|
background: linear-gradient(135deg, rgba(122,162,255,.95), rgba(98,230,183,.85)); |
||||||
|
border-color: transparent; |
||||||
|
color:#071017; |
||||||
|
font-weight:700; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn.primary:hover, |
||||||
|
.btn.primary:hover{ |
||||||
|
box-shadow:0 0 0 4px rgba(98,230,183,.18); |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Login / forms ===== */ |
||||||
|
.portal-sub{ |
||||||
|
color:var(--muted); |
||||||
|
margin:0 0 16px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form{ |
||||||
|
display:grid; |
||||||
|
gap:14px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form label{ |
||||||
|
display:block; |
||||||
|
font-weight:600; |
||||||
|
margin-bottom:6px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form input, |
||||||
|
.portal-form select, |
||||||
|
.pay-selector{ |
||||||
|
width:100%; |
||||||
|
padding:12px 14px; |
||||||
|
border-radius:12px; |
||||||
|
border:1px solid rgba(255,255,255,.14); |
||||||
|
background: rgba(255,255,255,.06); |
||||||
|
color:var(--text); |
||||||
|
box-sizing:border-box; |
||||||
|
outline:none; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form input:focus, |
||||||
|
.portal-form select:focus, |
||||||
|
.pay-selector:focus{ |
||||||
|
border-color:rgba(122,162,255,.65); |
||||||
|
box-shadow:0 0 0 4px rgba(122,162,255,.12); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-actions{ |
||||||
|
display:flex; |
||||||
|
gap:10px; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:4px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-note{ |
||||||
|
margin-top:16px; |
||||||
|
color:var(--muted); |
||||||
|
font-size:14px; |
||||||
|
line-height:1.5; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-msg, |
||||||
|
.error-box, |
||||||
|
.success-box{ |
||||||
|
margin-bottom:16px; |
||||||
|
padding:12px 14px; |
||||||
|
border-radius:12px; |
||||||
|
border:1px solid rgba(255,255,255,.16); |
||||||
|
background: rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
.error-box{ |
||||||
|
border-color: rgba(239, 68, 68, 0.55); |
||||||
|
background: rgba(127, 29, 29, 0.22); |
||||||
|
color: #fecaca; |
||||||
|
} |
||||||
|
|
||||||
|
.success-box{ |
||||||
|
border-color: rgba(34, 197, 94, 0.55); |
||||||
|
background: rgba(22, 101, 52, 0.18); |
||||||
|
color: #dcfce7; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Dashboard ===== */ |
||||||
|
.portal-wrap{ |
||||||
|
max-width:1100px; |
||||||
|
margin:0 auto; |
||||||
|
padding:0; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-grid, |
||||||
|
.detail-grid{ |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)); |
||||||
|
gap:14px; |
||||||
|
margin: 0 0 18px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card, |
||||||
|
.detail-card{ |
||||||
|
padding:18px; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card h3, |
||||||
|
.detail-card h3{ |
||||||
|
margin:0 0 8px 0; |
||||||
|
font-size:14px; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card .summary-value, |
||||||
|
.detail-card .detail-value{ |
||||||
|
font-size:28px; |
||||||
|
font-weight:800; |
||||||
|
line-height:1.1; |
||||||
|
color:var(--text); |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card .summary-sub{ |
||||||
|
margin-top:6px; |
||||||
|
font-size:12px; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.section-title{ |
||||||
|
margin:18px 0 10px 0; |
||||||
|
font-size:22px; |
||||||
|
} |
||||||
|
|
||||||
|
.table-card{ |
||||||
|
background: var(--card-soft); |
||||||
|
border: 1px solid rgba(255,255,255,.07); |
||||||
|
border-radius: var(--radius); |
||||||
|
box-shadow: var(--shadow); |
||||||
|
overflow:hidden; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Tables ===== */ |
||||||
|
table, |
||||||
|
.portal-table, |
||||||
|
.quote-table{ |
||||||
|
width:100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table th, |
||||||
|
table.portal-table td, |
||||||
|
.quote-table th, |
||||||
|
.quote-table td{ |
||||||
|
padding: 0.9rem 0.85rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.10); |
||||||
|
text-align: left; |
||||||
|
vertical-align: middle; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table th, |
||||||
|
.quote-table th{ |
||||||
|
background: rgba(255,255,255,.06); |
||||||
|
color: var(--text); |
||||||
|
font-size:13px; |
||||||
|
letter-spacing:.02em; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table tr:hover td, |
||||||
|
.quote-table tr:hover td{ |
||||||
|
background: rgba(255,255,255,.02); |
||||||
|
} |
||||||
|
|
||||||
|
.invoice-link{ |
||||||
|
color: var(--text); |
||||||
|
text-decoration: none; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.invoice-link:hover{ |
||||||
|
color: var(--accent); |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Badges ===== */ |
||||||
|
.status-badge, |
||||||
|
.quote-badge{ |
||||||
|
display: inline-block; |
||||||
|
padding: 0.22rem 0.62rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.82rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.status-paid{ background: rgba(34, 197, 94, 0.18); color: var(--success); } |
||||||
|
.status-pending{ background: rgba(245, 158, 11, 0.20); color: var(--warn); } |
||||||
|
.status-overdue{ background: rgba(239, 68, 68, 0.18); color: var(--danger); } |
||||||
|
.status-other{ background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } |
||||||
|
|
||||||
|
.quote-live{ background: rgba(34, 197, 94, 0.18); color: var(--success); } |
||||||
|
.quote-stale{ background: rgba(239, 68, 68, 0.18); color: var(--danger); } |
||||||
|
|
||||||
|
/* ===== Payments / invoice detail ===== */ |
||||||
|
.pay-card{ |
||||||
|
padding:18px; |
||||||
|
margin-top: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pay-selector-row{ |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
align-items:center; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pay-panel{ |
||||||
|
margin-top: 1rem; |
||||||
|
padding: 1rem; |
||||||
|
border: 1px solid rgba(255,255,255,0.12); |
||||||
|
border-radius: 12px; |
||||||
|
background: rgba(255,255,255,0.02); |
||||||
|
} |
||||||
|
|
||||||
|
.pay-panel.hidden{ display:none; } |
||||||
|
|
||||||
|
.pay-btn-square { background:#16a34a; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-wallet { background:#2563eb; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-mobile { background:#7c3aed; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-copy { background:#374151; border-color:transparent; color:#fff; } |
||||||
|
|
||||||
|
.snapshot-wrap{ |
||||||
|
position: relative; |
||||||
|
margin-top: 1rem; |
||||||
|
border: 1px solid rgba(255,255,255,0.14); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.02); |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-header{ |
||||||
|
display:flex; |
||||||
|
justify-content:space-between; |
||||||
|
gap:1rem; |
||||||
|
align-items:flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-meta{ |
||||||
|
flex: 1 1 auto; |
||||||
|
min-width: 0; |
||||||
|
line-height: 1.65; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-box{ |
||||||
|
width: 220px; |
||||||
|
min-height: 132px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
background: rgba(0,0,0,0.18); |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
justify-content:center; |
||||||
|
align-items:center; |
||||||
|
text-align:center; |
||||||
|
padding: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-value{ |
||||||
|
font-size: 2rem; |
||||||
|
font-weight: 800; |
||||||
|
line-height: 1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-label{ |
||||||
|
margin-top: 0.55rem; |
||||||
|
font-size: 0.95rem; |
||||||
|
opacity: 0.95; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-expired{ color: var(--danger); } |
||||||
|
|
||||||
|
.lock-box{ |
||||||
|
margin-top: 1rem; |
||||||
|
border: 1px solid rgba(34, 197, 94, 0.28); |
||||||
|
background: rgba(22, 101, 52, 0.16); |
||||||
|
border-radius: 12px; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.lock-box.expired{ |
||||||
|
border-color: rgba(239, 68, 68, 0.55); |
||||||
|
background: rgba(127, 29, 29, 0.22); |
||||||
|
} |
||||||
|
|
||||||
|
.lock-grid{ |
||||||
|
display:grid; |
||||||
|
grid-template-columns: 1fr 220px; |
||||||
|
gap:1rem; |
||||||
|
align-items:start; |
||||||
|
} |
||||||
|
|
||||||
|
.lock-code{ |
||||||
|
display:block; |
||||||
|
margin-top:0.35rem; |
||||||
|
padding:0.65rem 0.8rem; |
||||||
|
background: rgba(0,0,0,0.22); |
||||||
|
border-radius: 8px; |
||||||
|
overflow-wrap:anywhere; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-actions{ |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.9rem; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help{ |
||||||
|
margin-top: 0.85rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
border: 1px solid rgba(255,255,255,0.10); |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help h4{ |
||||||
|
margin: 0 0 0.55rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help p{ margin: 0.35rem 0; } |
||||||
|
.wallet-note{ opacity:0.9; margin-top:0.65rem; } |
||||||
|
.mono{ font-family: monospace; } |
||||||
|
|
||||||
|
.copy-row{ |
||||||
|
display:flex; |
||||||
|
gap:0.5rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
margin-top:0.65rem; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-target{ |
||||||
|
flex: 1 1 420px; |
||||||
|
min-width: 220px; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-status{ |
||||||
|
display:inline-block; |
||||||
|
margin-left: 0.5rem; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Footer ===== */ |
||||||
|
footer{ |
||||||
|
margin:28px auto 20px auto; |
||||||
|
max-width:1100px; |
||||||
|
padding:0 18px; |
||||||
|
color:var(--muted); |
||||||
|
font-size:13px; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Responsive ===== */ |
||||||
|
@media (max-width: 900px){ |
||||||
|
.summary-grid, |
||||||
|
.detail-grid{ |
||||||
|
grid-template-columns:1fr; |
||||||
|
} |
||||||
|
|
||||||
|
.nav{ |
||||||
|
align-items:flex-start; |
||||||
|
flex-direction:column; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks{ |
||||||
|
justify-content:flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.brand img{ |
||||||
|
height:54px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 820px){ |
||||||
|
.snapshot-header, |
||||||
|
.lock-grid{ |
||||||
|
grid-template-columns: 1fr; |
||||||
|
display:block; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-box{ |
||||||
|
width: 100%; |
||||||
|
margin-top: 1rem; |
||||||
|
min-height: 110px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/* ===== Fixed CAD / Oracle status bar ===== */ |
||||||
|
body{ |
||||||
|
padding-bottom: 56px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar{ |
||||||
|
position: fixed; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
z-index: 9999; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
min-height: 42px; |
||||||
|
padding: 8px 14px; |
||||||
|
background: rgba(8, 16, 32, 0.94); |
||||||
|
border-top: 1px solid rgba(255,255,255,.10); |
||||||
|
backdrop-filter: blur(8px); |
||||||
|
box-shadow: 0 -8px 24px rgba(0,0,0,.28); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar-inner{ |
||||||
|
width: 100%; |
||||||
|
max-width: 1100px; |
||||||
|
display: flex; |
||||||
|
gap: 10px; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
flex-wrap: wrap; |
||||||
|
text-align: center; |
||||||
|
color: var(--muted); |
||||||
|
font-size: 12px; |
||||||
|
line-height: 1.35; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar strong{ |
||||||
|
color: var(--text); |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar a{ |
||||||
|
color: var(--accent2); |
||||||
|
text-decoration: none; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar a:hover{ |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-dot{ |
||||||
|
width: 6px; |
||||||
|
height: 6px; |
||||||
|
border-radius: 999px; |
||||||
|
display: inline-block; |
||||||
|
background: rgba(255,255,255,.25); |
||||||
|
flex: 0 0 auto; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Payment method chips ===== */ |
||||||
|
.payment-method{ |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
gap: 7px; |
||||||
|
margin-top: 7px; |
||||||
|
padding: 4px 9px; |
||||||
|
border-radius: 999px; |
||||||
|
background: rgba(255,255,255,.05); |
||||||
|
border: 1px solid rgba(255,255,255,.08); |
||||||
|
color: var(--muted); |
||||||
|
font-size: 11px; |
||||||
|
font-weight: 700; |
||||||
|
letter-spacing: .01em; |
||||||
|
} |
||||||
|
|
||||||
|
.payment-method::before{ |
||||||
|
content: ""; |
||||||
|
width: 8px; |
||||||
|
height: 8px; |
||||||
|
border-radius: 999px; |
||||||
|
display: inline-block; |
||||||
|
background: #7aa2ff; |
||||||
|
box-shadow: 0 0 0 3px rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
.payment-square::before{ background: #7dd3fc; } |
||||||
|
.payment-etransfer::before{ background: #86efac; } |
||||||
|
.payment-etho::before{ background: #b084ff; } |
||||||
|
.payment-etica::before{ background: #4fd1c5; } |
||||||
|
.payment-alt::before{ background: #fbbf24; } |
||||||
|
.payment-cad::before{ background: #cbd5e1; } |
||||||
|
|
||||||
|
/* ===== Slightly stronger row hover ===== */ |
||||||
|
table.portal-table tbody tr:hover td, |
||||||
|
.quote-table tbody tr:hover td{ |
||||||
|
background: rgba(255,255,255,.035); |
||||||
|
transition: background .14s ease; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 700px){ |
||||||
|
body{ |
||||||
|
padding-bottom: 72px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar-inner{ |
||||||
|
font-size: 11px; |
||||||
|
line-height: 1.25; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/* ===== Shared services dropdown ===== */ |
||||||
|
.navlinks{ |
||||||
|
display:flex; |
||||||
|
gap:12px; |
||||||
|
flex-wrap:wrap; |
||||||
|
justify-content:flex-end; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown{ |
||||||
|
position:relative; |
||||||
|
display:inline-block; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-toggle{ |
||||||
|
display:inline-block; |
||||||
|
cursor:pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu{ |
||||||
|
position:absolute; |
||||||
|
top:calc(100% + 8px); |
||||||
|
right:0; |
||||||
|
min-width:220px; |
||||||
|
display:none; |
||||||
|
padding:10px; |
||||||
|
border-radius:14px; |
||||||
|
background:rgba(18,24,37,.98); |
||||||
|
border:1px solid rgba(255,255,255,.08); |
||||||
|
box-shadow:0 16px 40px rgba(0,0,0,.35); |
||||||
|
z-index:9999; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown:hover .dropdown-menu, |
||||||
|
.dropdown:focus-within .dropdown-menu{ |
||||||
|
display:block; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu a{ |
||||||
|
display:block; |
||||||
|
padding:9px 10px; |
||||||
|
border-radius:10px; |
||||||
|
color:var(--muted); |
||||||
|
text-decoration:none; |
||||||
|
white-space:nowrap; |
||||||
|
margin:0; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu a + a{ |
||||||
|
margin-top:4px; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu a:hover{ |
||||||
|
color:var(--text); |
||||||
|
background:rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 900px){ |
||||||
|
.dropdown{ |
||||||
|
width:100%; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-toggle{ |
||||||
|
width:100%; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu{ |
||||||
|
position:static; |
||||||
|
right:auto; |
||||||
|
top:auto; |
||||||
|
min-width:100%; |
||||||
|
margin-top:6px; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,168 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { |
||||||
|
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
.portal-actions a { |
||||||
|
margin-left: 0.75rem; |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
.summary-grid { |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
||||||
|
gap:1rem; |
||||||
|
margin: 1rem 0 1.25rem 0; |
||||||
|
} |
||||||
|
.summary-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
} |
||||||
|
.summary-card h3 { margin-top:0; margin-bottom:0.4rem; } |
||||||
|
table.portal-table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
table.portal-table th, table.portal-table td { |
||||||
|
padding: 0.8rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.12); |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
table.portal-table th { |
||||||
|
background: #e9eef7; |
||||||
|
color: #10203f; |
||||||
|
} |
||||||
|
.invoice-link { |
||||||
|
color: inherit; |
||||||
|
text-decoration: underline; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
.status-badge { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.18rem 0.55rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.86rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
.status-paid { |
||||||
|
background: rgba(34, 197, 94, 0.18); |
||||||
|
color: #4ade80; |
||||||
|
} |
||||||
|
.status-pending { |
||||||
|
background: rgba(245, 158, 11, 0.20); |
||||||
|
color: #fbbf24; |
||||||
|
} |
||||||
|
.status-overdue { |
||||||
|
background: rgba(239, 68, 68, 0.18); |
||||||
|
color: #f87171; |
||||||
|
} |
||||||
|
.status-other { |
||||||
|
background: rgba(148, 163, 184, 0.20); |
||||||
|
color: #cbd5e1; |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
|
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Client Dashboard</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/invoices/download-all">Download All Invoices (ZIP)</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div>{{ invoice_count }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div>{{ total_outstanding }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div>{{ total_paid }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoices</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { |
||||||
|
window.location.reload(); |
||||||
|
}, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,88 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Forgot Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
|
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Reset Portal Password</h1> |
||||||
|
<p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> |
||||||
|
|
||||||
|
{% if error %} |
||||||
|
<div class="portal-msg">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if message %} |
||||||
|
<div class="portal-msg">{{ message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/forgot-password"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" value="{{ form_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Send Reset Code</button> |
||||||
|
<a class="portal-btn" href="/portal">Back to Portal Login</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,733 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Invoice Detail - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom: 1rem; } |
||||||
|
.portal-actions a { margin-left: 0.75rem; text-decoration: underline; } |
||||||
|
.detail-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin: 1rem 0 1.25rem 0; } |
||||||
|
.detail-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-bottom: 1rem; } |
||||||
|
.detail-card h3 { margin-top: 0; margin-bottom: 0.4rem; } |
||||||
|
table.portal-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } |
||||||
|
table.portal-table th, table.portal-table td { padding: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; } |
||||||
|
table.portal-table th { background: #e9eef7; color: #10203f; } |
||||||
|
.status-badge { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.86rem; font-weight: 700; } |
||||||
|
.status-paid { background: rgba(34, 197, 94, 0.18); color: #4ade80; } |
||||||
|
.status-pending { background: rgba(245, 158, 11, 0.20); color: #fbbf24; } |
||||||
|
.status-overdue { background: rgba(239, 68, 68, 0.18); color: #f87171; } |
||||||
|
.status-other { background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } |
||||||
|
|
||||||
|
.pay-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-top: 1.25rem; } |
||||||
|
.pay-selector-row { display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; margin-top:0.75rem; } |
||||||
|
.pay-selector { padding: 10px 12px; min-width: 220px; border-radius: 8px; } |
||||||
|
.pay-panel { margin-top: 1rem; padding: 1rem; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(255,255,255,0.02); } |
||||||
|
.pay-panel.hidden { display: none; } |
||||||
|
|
||||||
|
.pay-btn { |
||||||
|
display:inline-block; |
||||||
|
padding:12px 18px; |
||||||
|
color:#ffffff; |
||||||
|
text-decoration:none; |
||||||
|
border-radius:8px; |
||||||
|
font-weight:700; |
||||||
|
border:none; |
||||||
|
cursor:pointer; |
||||||
|
margin:8px 0 0 0; |
||||||
|
} |
||||||
|
.pay-btn-square { background:#16a34a; } |
||||||
|
.pay-btn-wallet { background:#2563eb; } |
||||||
|
.pay-btn-mobile { background:#7c3aed; } |
||||||
|
.pay-btn-copy { background:#374151; } |
||||||
|
|
||||||
|
.error-box { border: 1px solid rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); color: #fecaca; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } |
||||||
|
.success-box { border: 1px solid rgba(34, 197, 94, 0.55); background: rgba(22, 101, 52, 0.18); color: #dcfce7; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } |
||||||
|
|
||||||
|
.snapshot-wrap { position: relative; margin-top: 1rem; border: 1px solid rgba(255,255,255,0.14); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.02); } |
||||||
|
.snapshot-header { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; } |
||||||
|
.snapshot-meta { flex: 1 1 auto; min-width: 0; line-height: 1.65; } |
||||||
|
.snapshot-timer-box { |
||||||
|
width: 220px; |
||||||
|
min-height: 132px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
background: rgba(0,0,0,0.18); |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
justify-content:center; |
||||||
|
align-items:center; |
||||||
|
text-align:center; |
||||||
|
padding: 0.9rem; |
||||||
|
} |
||||||
|
.snapshot-timer-value { font-size: 2rem; font-weight: 800; line-height: 1.1; } |
||||||
|
.snapshot-timer-label { margin-top: 0.55rem; font-size: 0.95rem; opacity: 0.95; } |
||||||
|
.snapshot-timer-expired { color: #f87171; } |
||||||
|
|
||||||
|
.quote-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } |
||||||
|
.quote-table th, .quote-table td { padding: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; vertical-align: top; } |
||||||
|
.quote-table th { background: #e9eef7; color: #10203f; } |
||||||
|
.quote-badge { display: inline-block; padding: 0.14rem 0.48rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; margin-left: 0.4rem; } |
||||||
|
.quote-live { background: rgba(34, 197, 94, 0.18); color: #4ade80; } |
||||||
|
.quote-stale { background: rgba(239, 68, 68, 0.18); color: #f87171; } |
||||||
|
.quote-pick-btn { padding: 8px 12px; border-radius: 8px; border: none; background: #2563eb; color: #fff; font-weight: 700; cursor: pointer; } |
||||||
|
.quote-pick-btn[disabled] { opacity: 0.5; cursor: not-allowed; } |
||||||
|
|
||||||
|
.lock-box { margin-top: 1rem; border: 1px solid rgba(34, 197, 94, 0.28); background: rgba(22, 101, 52, 0.16); border-radius: 12px; padding: 1rem; } |
||||||
|
.lock-box.expired { border-color: rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); } |
||||||
|
.lock-grid { display:grid; grid-template-columns: 1fr 220px; gap:1rem; align-items:start; } |
||||||
|
.lock-code { display:block; margin-top:0.35rem; padding:0.65rem 0.8rem; background: rgba(0,0,0,0.22); border-radius: 8px; overflow-wrap:anywhere; } |
||||||
|
|
||||||
|
.wallet-actions { |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.9rem; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
.wallet-help { |
||||||
|
margin-top: 0.85rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
border: 1px solid rgba(255,255,255,0.10); |
||||||
|
} |
||||||
|
.wallet-help h4 { |
||||||
|
margin: 0 0 0.55rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
.wallet-help p { |
||||||
|
margin: 0.35rem 0; |
||||||
|
} |
||||||
|
.wallet-note { opacity:0.9; margin-top:0.65rem; } |
||||||
|
.mono { font-family: monospace; } |
||||||
|
.copy-row { |
||||||
|
display:flex; |
||||||
|
gap:0.5rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
margin-top:0.65rem; |
||||||
|
} |
||||||
|
.copy-target { |
||||||
|
flex: 1 1 420px; |
||||||
|
min-width: 220px; |
||||||
|
} |
||||||
|
.copy-status { |
||||||
|
display:inline-block; |
||||||
|
margin-left: 0.5rem; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 820px) { |
||||||
|
.snapshot-header, .lock-grid { grid-template-columns: 1fr; display:block; } |
||||||
|
.snapshot-timer-box { width: 100%; margin-top: 1rem; min-height: 110px; } |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
|
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Invoice Detail</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/dashboard">Back to Dashboard</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if (invoice.status or "")|lower == "paid" %} |
||||||
|
<div class="success-box">✓ This invoice has been paid. Thank you!</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if crypto_error %} |
||||||
|
<div class="error-box">{{ crypto_error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<div class="detail-grid"> |
||||||
|
<div class="detail-card"><h3>Invoice</h3><div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div></div> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Status</h3> |
||||||
|
{% set s = (invoice.status or "")|lower %} |
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} |
||||||
|
<span class="status-badge status-pending">processing</span> |
||||||
|
{% elif s == "paid" %}<span class="status-badge status-paid">{{ invoice.status }}</span> |
||||||
|
{% elif s == "pending" %}<span class="status-badge status-pending">{{ invoice.status }}</span> |
||||||
|
{% elif s == "overdue" %}<span class="status-badge status-overdue">{{ invoice.status }}</span> |
||||||
|
{% else %}<span class="status-badge status-other">{{ invoice.status }}</span>{% endif %} |
||||||
|
</div> |
||||||
|
<div class="detail-card"><h3>Created</h3><div>{{ invoice.created_at }}</div></div> |
||||||
|
<div class="detail-card"><h3>Total</h3><div>{{ invoice.total_amount }}</div></div> |
||||||
|
<div class="detail-card"><h3>Paid</h3><div>{{ invoice.amount_paid }}</div></div> |
||||||
|
<div class="detail-card"><h3>Outstanding</h3><div>{{ invoice.outstanding }}</div></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoice Items</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Line Total</th></tr></thead> |
||||||
|
<tbody> |
||||||
|
{% for item in items %} |
||||||
|
<tr><td>{{ item.description }}</td><td>{{ item.quantity }}</td><td>{{ item.unit_price }}</td><td>{{ item.line_total }}</td></tr> |
||||||
|
{% else %} |
||||||
|
<tr><td colspan="4">No invoice line items found.</td></tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
|
||||||
|
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %} |
||||||
|
<div class="pay-card"> |
||||||
|
<h3>Pay Now</h3> |
||||||
|
<div class="pay-selector-row"> |
||||||
|
<label for="payMethodSelect"><strong>Choose payment method:</strong></label> |
||||||
|
<select id="payMethodSelect" class="pay-selector"> |
||||||
|
<option value="" {% if not pay_mode %}selected{% endif %}>Select…</option> |
||||||
|
<option value="etransfer" {% if pay_mode == "etransfer" %}selected{% endif %}>e-Transfer</option> |
||||||
|
<option value="square" {% if pay_mode == "square" %}selected{% endif %}>Credit Card</option> |
||||||
|
<option value="crypto" {% if pay_mode == "crypto" %}selected{% endif %}>Crypto</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}"> |
||||||
|
<p><strong>Interac e-Transfer</strong><br>Send payment to:<br>payment@outsidethebox.top<br>Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}"> |
||||||
|
<p><strong>Credit Card (Square)</strong></p> |
||||||
|
<a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-square">Pay with Credit Card</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}"> |
||||||
|
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %} |
||||||
|
<div class="snapshot-wrap"> |
||||||
|
<div class="snapshot-header"> |
||||||
|
<div class="snapshot-meta"> |
||||||
|
<h3 style="margin-top:0;">Crypto Quote Snapshot</h3> |
||||||
|
<div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_at or "—" }}</div> |
||||||
|
<div><strong>Source Status:</strong> {{ invoice.oracle_quote.source_status or "—" }}</div> |
||||||
|
<div><strong>Frozen Amount:</strong> {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}</div> |
||||||
|
{% if pending_crypto_payment %} |
||||||
|
<div style="margin-top:0.75rem;"><strong>Your quote is protected after acceptance.</strong></div> |
||||||
|
{% else %} |
||||||
|
<div style="margin-top:0.75rem;"><strong>Select a crypto asset to accept the quote.</strong></div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="processingTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div> |
||||||
|
<div id="processingTimerLabel" class="snapshot-timer-label">Watching transaction / waiting for confirmation</div> |
||||||
|
</div> |
||||||
|
{% elif pending_crypto_payment %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="lockTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div> |
||||||
|
<div id="lockTimerLabel" class="snapshot-timer-label">Quote protected while you open wallet</div> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="quoteTimerValue" class="snapshot-timer-value" data-expiry="{{ crypto_quote_window_expires_iso }}">--:--</div> |
||||||
|
<div id="quoteTimerLabel" class="snapshot-timer-label">This price times out:</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if pending_crypto_payment and selected_crypto_option %} |
||||||
|
<div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired or pending_crypto_payment.processing_expired %} expired{% endif %}"> |
||||||
|
<div class="lock-grid"> |
||||||
|
<div> |
||||||
|
<h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3> |
||||||
|
<div><strong>Send exactly:</strong> {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}</div> |
||||||
|
<div style="margin-top:0.65rem;"><strong>Destination wallet:</strong></div> |
||||||
|
<code id="walletAddressText" class="lock-code copy-target">{{ pending_crypto_payment.wallet_address }}</code> |
||||||
|
<div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div> |
||||||
|
<code id="invoiceRefText" class="lock-code copy-target">{{ pending_crypto_payment.reference }}</code> |
||||||
|
|
||||||
|
{% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %} |
||||||
|
<div class="wallet-actions"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
id="walletPayButton" |
||||||
|
class="pay-btn pay-btn-wallet" |
||||||
|
data-invoice-id="{{ invoice.id }}" |
||||||
|
data-payment-id="{{ pending_crypto_payment.id }}" |
||||||
|
data-asset="{{ selected_crypto_option.symbol }}" |
||||||
|
data-chain-id="{{ selected_crypto_option.chain_id }}" |
||||||
|
data-asset-type="{{ selected_crypto_option.asset_type }}" |
||||||
|
data-to="{{ selected_crypto_option.wallet_address }}" |
||||||
|
data-amount="{{ pending_crypto_payment.payment_amount }}" |
||||||
|
data-decimals="{{ selected_crypto_option.decimals }}" |
||||||
|
data-token-contract="{{ selected_crypto_option.token_contract or '' }}" |
||||||
|
data-chain-add='{{ (selected_crypto_option.chain_add_params or {})|tojson|safe }}' |
||||||
|
> |
||||||
|
Open MetaMask / Rabby |
||||||
|
</button> |
||||||
|
|
||||||
|
<a |
||||||
|
id="metamaskMobileLink" |
||||||
|
href="#" |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
class="pay-btn pay-btn-mobile" |
||||||
|
data-invoice-id="{{ invoice.id }}" |
||||||
|
> |
||||||
|
Open in MetaMask Mobile |
||||||
|
</a> |
||||||
|
|
||||||
|
<button type="button" id="copyDetailsButton" class="pay-btn pay-btn-copy"> |
||||||
|
Copy Payment Details |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="wallet-help"> |
||||||
|
<h4>Fastest way to pay</h4> |
||||||
|
<p>1. Click <strong>Open MetaMask / Rabby</strong> if your wallet is installed in this browser.</p> |
||||||
|
<p>2. If that does not open your wallet, click <strong>Open in MetaMask Mobile</strong>.</p> |
||||||
|
<p>3. If needed, use <strong>Copy Payment Details</strong> and send manually.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="wallet-note"> |
||||||
|
You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet. |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="copy-row"> |
||||||
|
<span id="walletStatusText"></span> |
||||||
|
<span id="copyStatusText" class="copy-status"></span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% elif pending_crypto_payment.txid %} |
||||||
|
<div style="margin-top:0.9rem;"><strong>Transaction Hash:</strong></div> |
||||||
|
<code class="lock-code mono">{{ pending_crypto_payment.txid }}</code> |
||||||
|
<div style="margin-top:0.75rem;">Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.</div> |
||||||
|
{% elif pending_crypto_payment.lock_expired %} |
||||||
|
<div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto"> |
||||||
|
<table class="quote-table"> |
||||||
|
<thead><tr><th>Asset</th><th>Quoted Amount</th><th>CAD Price</th><th>Status</th><th>Action</th></tr></thead> |
||||||
|
<tbody> |
||||||
|
{% for q in crypto_options %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
{{ q.label }} |
||||||
|
{% if q.recommended %}<span class="quote-badge quote-live">recommended</span>{% endif %} |
||||||
|
{% if q.wallet_capable %}<span class="quote-badge quote-live">wallet</span>{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ q.display_amount or "—" }}</td> |
||||||
|
<td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td> |
||||||
|
<td>{% if q.available %}<span class="quote-badge quote-live">live</span>{% else %}<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>{% endif %}</td> |
||||||
|
<td><button type="submit" name="asset" value="{{ q.symbol }}" class="quote-pick-btn" {% if not q.available %}disabled{% endif %}>Accept {{ q.symbol }}</button></td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</form> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<p>No crypto quote snapshot is available for this invoice yet.</p> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if invoice_payments %} |
||||||
|
<div class="detail-card" style="margin-top:1.25rem;"> |
||||||
|
<h3>Payments Applied</h3> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Method</th> |
||||||
|
<th>Amount</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Received</th> |
||||||
|
<th>Reference / TXID</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for p in invoice_payments %} |
||||||
|
<tr> |
||||||
|
<td>{{ p.payment_method_label }}</td> |
||||||
|
<td>{{ p.payment_amount_display }} {{ p.payment_currency }}</td> |
||||||
|
<td>{{ p.payment_status }}</td> |
||||||
|
<td>{{ p.received_at_local }}</td> |
||||||
|
<td> |
||||||
|
{% if p.txid %} |
||||||
|
{{ p.txid }} |
||||||
|
{% elif p.reference %} |
||||||
|
{{ p.reference }} |
||||||
|
{% else %} |
||||||
|
- |
||||||
|
{% endif %} |
||||||
|
{% if p.wallet_address %}<br><small>{{ p.wallet_address }}</small>{% endif %} |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if pdf_url %} |
||||||
|
<div style="margin-top:1rem;"><a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a></div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
const select = document.getElementById("payMethodSelect"); |
||||||
|
if (select) { |
||||||
|
select.addEventListener("change", function() { |
||||||
|
const value = this.value || ""; |
||||||
|
const url = new URL(window.location.href); |
||||||
|
if (!value) { |
||||||
|
url.searchParams.delete("pay"); |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.delete("refresh_quote"); |
||||||
|
} else { |
||||||
|
url.searchParams.set("pay", value); |
||||||
|
if (value !== "crypto") { |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.delete("refresh_quote"); |
||||||
|
} else { |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.set("refresh_quote", "1"); |
||||||
|
} |
||||||
|
} |
||||||
|
window.location.href = url.toString(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function bindCountdown(valueId, labelId, expireIso, expiredMessage, disableSelector) { |
||||||
|
const valueEl = document.getElementById(valueId); |
||||||
|
const labelEl = document.getElementById(labelId); |
||||||
|
if (!valueEl || !expireIso) return; |
||||||
|
|
||||||
|
function tick() { |
||||||
|
const end = new Date(expireIso).getTime(); |
||||||
|
const now = Date.now(); |
||||||
|
const diff = Math.max(0, Math.floor((end - now) / 1000)); |
||||||
|
|
||||||
|
if (diff <= 0) { |
||||||
|
valueEl.textContent = "00:00"; |
||||||
|
valueEl.classList.add("snapshot-timer-expired"); |
||||||
|
if (labelEl) { |
||||||
|
labelEl.textContent = expiredMessage; |
||||||
|
labelEl.classList.add("snapshot-timer-expired"); |
||||||
|
} |
||||||
|
if (disableSelector) { |
||||||
|
document.querySelectorAll(disableSelector).forEach(btn => btn.disabled = true); |
||||||
|
} |
||||||
|
const lockBox = document.getElementById("lockBox"); |
||||||
|
if (lockBox) lockBox.classList.add("expired"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const m = String(Math.floor(diff / 60)).padStart(2, "0"); |
||||||
|
const s = String(diff % 60).padStart(2, "0"); |
||||||
|
valueEl.textContent = `${m}:${s}`; |
||||||
|
setTimeout(tick, 250); |
||||||
|
} |
||||||
|
|
||||||
|
tick(); |
||||||
|
} |
||||||
|
|
||||||
|
const quoteTimer = document.getElementById("quoteTimerValue"); |
||||||
|
if (quoteTimer && quoteTimer.dataset.expiry) { |
||||||
|
bindCountdown("quoteTimerValue", "quoteTimerLabel", quoteTimer.dataset.expiry, "price has expired - please refresh your view to update", "#cryptoPickForm button"); |
||||||
|
} |
||||||
|
|
||||||
|
const lockTimer = document.getElementById("lockTimerValue"); |
||||||
|
if (lockTimer && lockTimer.dataset.expiry) { |
||||||
|
bindCountdown("lockTimerValue", "lockTimerLabel", lockTimer.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton"); |
||||||
|
} |
||||||
|
|
||||||
|
const processingTimer = document.getElementById("processingTimerValue"); |
||||||
|
if (processingTimer && processingTimer.dataset.expiry) { |
||||||
|
bindCountdown("processingTimerValue", "processingTimerLabel", processingTimer.dataset.expiry, "price has expired - please refresh your quote to update", null); |
||||||
|
} |
||||||
|
|
||||||
|
function toHexBigIntFromDecimal(amountText, decimals) { |
||||||
|
const text = String(amountText || "0"); |
||||||
|
const parts = text.split("."); |
||||||
|
const whole = parts[0] || "0"; |
||||||
|
const frac = (parts[1] || "").padEnd(decimals, "0").slice(0, decimals); |
||||||
|
const combined = (whole + frac).replace(/^0+/, "") || "0"; |
||||||
|
return "0x" + BigInt(combined).toString(16); |
||||||
|
} |
||||||
|
|
||||||
|
function erc20TransferData(to, amountText, decimals) { |
||||||
|
const method = "a9059cbb"; |
||||||
|
const addr = String(to || "").toLowerCase().replace(/^0x/, "").padStart(64, "0"); |
||||||
|
const amtHex = BigInt(toHexBigIntFromDecimal(amountText, decimals)).toString(16).padStart(64, "0"); |
||||||
|
return "0x" + method + addr + amtHex; |
||||||
|
} |
||||||
|
|
||||||
|
async function switchChain(chainId, chainAddParams) { |
||||||
|
const hexChainId = "0x" + Number(chainId).toString(16); |
||||||
|
try { |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_switchEthereumChain", |
||||||
|
params: [{ chainId: hexChainId }] |
||||||
|
}); |
||||||
|
return; |
||||||
|
} catch (err) { |
||||||
|
const code = err && (err.code ?? err?.data?.originalError?.code); |
||||||
|
if ((code === 4902 || String(err).includes("4902")) && chainAddParams) { |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_addEthereumChain", |
||||||
|
params: [chainAddParams] |
||||||
|
}); |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_switchEthereumChain", |
||||||
|
params: [{ chainId: hexChainId }] |
||||||
|
}); |
||||||
|
return; |
||||||
|
} |
||||||
|
throw err; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function buildMetaMaskMobileLink() { |
||||||
|
const currentUrl = window.location.href; |
||||||
|
return "https://link.metamask.io/dapp/" + currentUrl.replace(/^https?:\/\//, ""); |
||||||
|
} |
||||||
|
|
||||||
|
const mmLink = document.getElementById("metamaskMobileLink"); |
||||||
|
if (mmLink) { |
||||||
|
mmLink.href = buildMetaMaskMobileLink(); |
||||||
|
} |
||||||
|
|
||||||
|
async function copyText(text) { |
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) { |
||||||
|
await navigator.clipboard.writeText(text); |
||||||
|
return; |
||||||
|
} |
||||||
|
const ta = document.createElement("textarea"); |
||||||
|
ta.value = text; |
||||||
|
document.body.appendChild(ta); |
||||||
|
ta.select(); |
||||||
|
document.execCommand("copy"); |
||||||
|
ta.remove(); |
||||||
|
} |
||||||
|
|
||||||
|
const copyBtn = document.getElementById("copyDetailsButton"); |
||||||
|
if (copyBtn) { |
||||||
|
copyBtn.addEventListener("click", async function() { |
||||||
|
const copyStatus = document.getElementById("copyStatusText"); |
||||||
|
const walletAddress = document.getElementById("walletAddressText")?.textContent || ""; |
||||||
|
const invoiceRef = document.getElementById("invoiceRefText")?.textContent || ""; |
||||||
|
const amount = document.getElementById("walletPayButton")?.dataset.amount || ""; |
||||||
|
const asset = document.getElementById("walletPayButton")?.dataset.asset || ""; |
||||||
|
|
||||||
|
const payload = |
||||||
|
`Asset: ${asset} |
||||||
|
Amount: ${amount} ${asset} |
||||||
|
Wallet: ${walletAddress} |
||||||
|
Reference: ${invoiceRef}`; |
||||||
|
|
||||||
|
try { |
||||||
|
await copyText(payload); |
||||||
|
if (copyStatus) copyStatus.textContent = "Payment details copied."; |
||||||
|
} catch (err) { |
||||||
|
if (copyStatus) copyStatus.textContent = "Copy failed."; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const walletButton = document.getElementById("walletPayButton"); |
||||||
|
|
||||||
|
function pendingTxStorageKey(invoiceId, paymentId) { |
||||||
|
return `otb_pending_tx_${invoiceId}_${paymentId}`; |
||||||
|
} |
||||||
|
|
||||||
|
async function submitTxHash(invoiceId, paymentId, asset, txHash) { |
||||||
|
const res = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { |
||||||
|
method: "POST", |
||||||
|
headers: { |
||||||
|
"Content-Type": "application/json", |
||||||
|
"Accept": "application/json" |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
payment_id: paymentId, |
||||||
|
asset: asset, |
||||||
|
tx_hash: txHash |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
let data = {}; |
||||||
|
try { |
||||||
|
data = await res.json(); |
||||||
|
} catch (err) { |
||||||
|
data = { ok: false, error: "invalid_json_response" }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!res.ok || !data.ok) { |
||||||
|
throw new Error(data.error || `submit_failed_http_${res.status}`); |
||||||
|
} |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
async function tryRecoverPendingTxFromStorage() { |
||||||
|
const invoiceId = "{{ invoice.id }}"; |
||||||
|
const paymentId = "{{ pending_crypto_payment.id if pending_crypto_payment else '' }}"; |
||||||
|
const asset = "{{ selected_crypto_option.symbol if selected_crypto_option else '' }}"; |
||||||
|
|
||||||
|
if (!invoiceId || !paymentId || !asset) return; |
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||||
|
return; |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
const key = pendingTxStorageKey(invoiceId, paymentId); |
||||||
|
const savedTx = localStorage.getItem(key); |
||||||
|
if (!savedTx || !savedTx.startsWith("0x")) return; |
||||||
|
|
||||||
|
const walletStatus = document.getElementById("walletStatusText"); |
||||||
|
try { |
||||||
|
if (walletStatus) walletStatus.textContent = "Retrying saved transaction submission..."; |
||||||
|
await submitTxHash(invoiceId, paymentId, asset, savedTx); |
||||||
|
localStorage.removeItem(key); |
||||||
|
const url = new URL(window.location.href); |
||||||
|
url.searchParams.set("pay", "crypto"); |
||||||
|
url.searchParams.set("asset", asset); |
||||||
|
url.searchParams.set("payment_id", paymentId); |
||||||
|
window.location.href = url.toString(); |
||||||
|
} catch (err) { |
||||||
|
if (walletStatus) walletStatus.textContent = `Saved tx retry failed: ${err.message}`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (walletButton) { |
||||||
|
walletButton.addEventListener("click", async function() { |
||||||
|
const walletStatus = document.getElementById("walletStatusText"); |
||||||
|
const invoiceId = this.dataset.invoiceId; |
||||||
|
const paymentId = this.dataset.paymentId; |
||||||
|
const asset = this.dataset.asset; |
||||||
|
const chainId = this.dataset.chainId; |
||||||
|
const assetType = this.dataset.assetType; |
||||||
|
const to = this.dataset.to; |
||||||
|
const amount = this.dataset.amount; |
||||||
|
const decimals = Number(this.dataset.decimals || "18"); |
||||||
|
const tokenContract = this.dataset.tokenContract || ""; |
||||||
|
let chainAddParams = null; |
||||||
|
try { |
||||||
|
chainAddParams = this.dataset.chainAdd ? JSON.parse(this.dataset.chainAdd) : null; |
||||||
|
} catch (err) { |
||||||
|
chainAddParams = null; |
||||||
|
} |
||||||
|
|
||||||
|
const setStatus = (msg) => { |
||||||
|
if (walletStatus) walletStatus.textContent = msg; |
||||||
|
}; |
||||||
|
|
||||||
|
if (!window.ethereum || !window.ethereum.request) { |
||||||
|
setStatus("No browser wallet detected. Use MetaMask/Rabby or MetaMask Mobile."); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
this.disabled = true; |
||||||
|
setStatus("Opening wallet..."); |
||||||
|
|
||||||
|
await window.ethereum.request({ method: "eth_requestAccounts" }); |
||||||
|
|
||||||
|
if (chainId && chainId !== "None" && chainId !== "") { |
||||||
|
try { |
||||||
|
await switchChain(Number(chainId), chainAddParams); |
||||||
|
} catch (err) { |
||||||
|
setStatus(`Chain switch failed: ${err.message || err}`); |
||||||
|
this.disabled = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let txParams; |
||||||
|
if (assetType === "token" && tokenContract) { |
||||||
|
txParams = { |
||||||
|
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||||
|
to: tokenContract, |
||||||
|
data: erc20TransferData(to, amount, decimals), |
||||||
|
value: "0x0" |
||||||
|
}; |
||||||
|
} else { |
||||||
|
txParams = { |
||||||
|
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||||
|
to: to, |
||||||
|
value: toHexBigIntFromDecimal(amount, decimals) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
setStatus("Waiting for wallet confirmation..."); |
||||||
|
const txHash = await window.ethereum.request({ |
||||||
|
method: "eth_sendTransaction", |
||||||
|
params: [txParams] |
||||||
|
}); |
||||||
|
|
||||||
|
if (!txHash || !String(txHash).startsWith("0x")) { |
||||||
|
throw new Error("wallet did not return a tx hash"); |
||||||
|
} |
||||||
|
|
||||||
|
const storageKey = pendingTxStorageKey(invoiceId, paymentId); |
||||||
|
localStorage.setItem(storageKey, String(txHash)); |
||||||
|
|
||||||
|
setStatus(`Wallet submitted tx: ${txHash}. Sending to billing server...`); |
||||||
|
|
||||||
|
await submitTxHash(invoiceId, paymentId, asset, txHash); |
||||||
|
|
||||||
|
localStorage.removeItem(storageKey); |
||||||
|
|
||||||
|
setStatus("Transaction submitted. Reloading into processing view..."); |
||||||
|
const url = new URL(window.location.href); |
||||||
|
url.searchParams.set("pay", "crypto"); |
||||||
|
url.searchParams.set("asset", asset); |
||||||
|
url.searchParams.set("payment_id", paymentId); |
||||||
|
window.location.href = url.toString(); |
||||||
|
} catch (err) { |
||||||
|
setStatus(`Wallet submit failed: ${err.message || err}`); |
||||||
|
this.disabled = false; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
tryRecoverPendingTxFromStorage(); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
const processingAutoRefreshEnabled = {{ 'true' if pending_crypto_payment and pending_crypto_payment.txid and (invoice.status or '')|lower != 'paid' else 'false' }}; |
||||||
|
if (processingAutoRefreshEnabled) { |
||||||
|
setTimeout(function() { |
||||||
|
window.location.reload(); |
||||||
|
}, 10000); |
||||||
|
} |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,102 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-note { margin-top: 1rem; opacity: 0.88; font-size: 0.95rem; } |
||||||
|
.portal-links { margin-top: 1rem; } |
||||||
|
.portal-links a { margin-right: 1rem; } |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
|
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="https://outsidethebox.top/">Home</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,82 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Set Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
|
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Create Your Portal Password</h1> |
||||||
|
<p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/set-password"> |
||||||
|
<div> |
||||||
|
<label for="password">New Password</label> |
||||||
|
<input id="password" name="password" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label for="password2">Confirm Password</label> |
||||||
|
<input id="password2" name="password2" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<button class="portal-btn" type="submit">Set Password</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,162 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { |
||||||
|
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
.portal-actions a { |
||||||
|
margin-left: 0.75rem; |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
.summary-grid { |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
||||||
|
gap:1rem; |
||||||
|
margin: 1rem 0 1.25rem 0; |
||||||
|
} |
||||||
|
.summary-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
} |
||||||
|
.summary-card h3 { margin-top:0; margin-bottom:0.4rem; } |
||||||
|
table.portal-table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
table.portal-table th, table.portal-table td { |
||||||
|
padding: 0.8rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.12); |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
table.portal-table th { |
||||||
|
background: #e9eef7; |
||||||
|
color: #10203f; |
||||||
|
} |
||||||
|
.invoice-link { |
||||||
|
color: inherit; |
||||||
|
text-decoration: underline; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
.status-badge { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.18rem 0.55rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.86rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
.status-paid { |
||||||
|
background: rgba(34, 197, 94, 0.18); |
||||||
|
color: #4ade80; |
||||||
|
} |
||||||
|
.status-pending { |
||||||
|
background: rgba(245, 158, 11, 0.20); |
||||||
|
color: #fbbf24; |
||||||
|
} |
||||||
|
.status-overdue { |
||||||
|
background: rgba(239, 68, 68, 0.18); |
||||||
|
color: #f87171; |
||||||
|
} |
||||||
|
.status-other { |
||||||
|
background: rgba(148, 163, 184, 0.20); |
||||||
|
color: #cbd5e1; |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Client Dashboard</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/invoices/download-all">Download All Invoices (ZIP)</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div>{{ invoice_count }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div>{{ total_outstanding }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div>{{ total_paid }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoices</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { |
||||||
|
window.location.reload(); |
||||||
|
}, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,82 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Forgot Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Reset Portal Password</h1> |
||||||
|
<p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> |
||||||
|
|
||||||
|
{% if error %} |
||||||
|
<div class="portal-msg">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if message %} |
||||||
|
<div class="portal-msg">{{ message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/forgot-password"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" value="{{ form_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Send Reset Code</button> |
||||||
|
<a class="portal-btn" href="/portal">Back to Portal Login</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,727 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Invoice Detail - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom: 1rem; } |
||||||
|
.portal-actions a { margin-left: 0.75rem; text-decoration: underline; } |
||||||
|
.detail-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin: 1rem 0 1.25rem 0; } |
||||||
|
.detail-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-bottom: 1rem; } |
||||||
|
.detail-card h3 { margin-top: 0; margin-bottom: 0.4rem; } |
||||||
|
table.portal-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } |
||||||
|
table.portal-table th, table.portal-table td { padding: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; } |
||||||
|
table.portal-table th { background: #e9eef7; color: #10203f; } |
||||||
|
.status-badge { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.86rem; font-weight: 700; } |
||||||
|
.status-paid { background: rgba(34, 197, 94, 0.18); color: #4ade80; } |
||||||
|
.status-pending { background: rgba(245, 158, 11, 0.20); color: #fbbf24; } |
||||||
|
.status-overdue { background: rgba(239, 68, 68, 0.18); color: #f87171; } |
||||||
|
.status-other { background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } |
||||||
|
|
||||||
|
.pay-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-top: 1.25rem; } |
||||||
|
.pay-selector-row { display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; margin-top:0.75rem; } |
||||||
|
.pay-selector { padding: 10px 12px; min-width: 220px; border-radius: 8px; } |
||||||
|
.pay-panel { margin-top: 1rem; padding: 1rem; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(255,255,255,0.02); } |
||||||
|
.pay-panel.hidden { display: none; } |
||||||
|
|
||||||
|
.pay-btn { |
||||||
|
display:inline-block; |
||||||
|
padding:12px 18px; |
||||||
|
color:#ffffff; |
||||||
|
text-decoration:none; |
||||||
|
border-radius:8px; |
||||||
|
font-weight:700; |
||||||
|
border:none; |
||||||
|
cursor:pointer; |
||||||
|
margin:8px 0 0 0; |
||||||
|
} |
||||||
|
.pay-btn-square { background:#16a34a; } |
||||||
|
.pay-btn-wallet { background:#2563eb; } |
||||||
|
.pay-btn-mobile { background:#7c3aed; } |
||||||
|
.pay-btn-copy { background:#374151; } |
||||||
|
|
||||||
|
.error-box { border: 1px solid rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); color: #fecaca; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } |
||||||
|
.success-box { border: 1px solid rgba(34, 197, 94, 0.55); background: rgba(22, 101, 52, 0.18); color: #dcfce7; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } |
||||||
|
|
||||||
|
.snapshot-wrap { position: relative; margin-top: 1rem; border: 1px solid rgba(255,255,255,0.14); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.02); } |
||||||
|
.snapshot-header { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; } |
||||||
|
.snapshot-meta { flex: 1 1 auto; min-width: 0; line-height: 1.65; } |
||||||
|
.snapshot-timer-box { |
||||||
|
width: 220px; |
||||||
|
min-height: 132px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
background: rgba(0,0,0,0.18); |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
justify-content:center; |
||||||
|
align-items:center; |
||||||
|
text-align:center; |
||||||
|
padding: 0.9rem; |
||||||
|
} |
||||||
|
.snapshot-timer-value { font-size: 2rem; font-weight: 800; line-height: 1.1; } |
||||||
|
.snapshot-timer-label { margin-top: 0.55rem; font-size: 0.95rem; opacity: 0.95; } |
||||||
|
.snapshot-timer-expired { color: #f87171; } |
||||||
|
|
||||||
|
.quote-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } |
||||||
|
.quote-table th, .quote-table td { padding: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; vertical-align: top; } |
||||||
|
.quote-table th { background: #e9eef7; color: #10203f; } |
||||||
|
.quote-badge { display: inline-block; padding: 0.14rem 0.48rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; margin-left: 0.4rem; } |
||||||
|
.quote-live { background: rgba(34, 197, 94, 0.18); color: #4ade80; } |
||||||
|
.quote-stale { background: rgba(239, 68, 68, 0.18); color: #f87171; } |
||||||
|
.quote-pick-btn { padding: 8px 12px; border-radius: 8px; border: none; background: #2563eb; color: #fff; font-weight: 700; cursor: pointer; } |
||||||
|
.quote-pick-btn[disabled] { opacity: 0.5; cursor: not-allowed; } |
||||||
|
|
||||||
|
.lock-box { margin-top: 1rem; border: 1px solid rgba(34, 197, 94, 0.28); background: rgba(22, 101, 52, 0.16); border-radius: 12px; padding: 1rem; } |
||||||
|
.lock-box.expired { border-color: rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); } |
||||||
|
.lock-grid { display:grid; grid-template-columns: 1fr 220px; gap:1rem; align-items:start; } |
||||||
|
.lock-code { display:block; margin-top:0.35rem; padding:0.65rem 0.8rem; background: rgba(0,0,0,0.22); border-radius: 8px; overflow-wrap:anywhere; } |
||||||
|
|
||||||
|
.wallet-actions { |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.9rem; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
.wallet-help { |
||||||
|
margin-top: 0.85rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
border: 1px solid rgba(255,255,255,0.10); |
||||||
|
} |
||||||
|
.wallet-help h4 { |
||||||
|
margin: 0 0 0.55rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
.wallet-help p { |
||||||
|
margin: 0.35rem 0; |
||||||
|
} |
||||||
|
.wallet-note { opacity:0.9; margin-top:0.65rem; } |
||||||
|
.mono { font-family: monospace; } |
||||||
|
.copy-row { |
||||||
|
display:flex; |
||||||
|
gap:0.5rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
margin-top:0.65rem; |
||||||
|
} |
||||||
|
.copy-target { |
||||||
|
flex: 1 1 420px; |
||||||
|
min-width: 220px; |
||||||
|
} |
||||||
|
.copy-status { |
||||||
|
display:inline-block; |
||||||
|
margin-left: 0.5rem; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 820px) { |
||||||
|
.snapshot-header, .lock-grid { grid-template-columns: 1fr; display:block; } |
||||||
|
.snapshot-timer-box { width: 100%; margin-top: 1rem; min-height: 110px; } |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Invoice Detail</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/dashboard">Back to Dashboard</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if (invoice.status or "")|lower == "paid" %} |
||||||
|
<div class="success-box">✓ This invoice has been paid. Thank you!</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if crypto_error %} |
||||||
|
<div class="error-box">{{ crypto_error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<div class="detail-grid"> |
||||||
|
<div class="detail-card"><h3>Invoice</h3><div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div></div> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Status</h3> |
||||||
|
{% set s = (invoice.status or "")|lower %} |
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} |
||||||
|
<span class="status-badge status-pending">processing</span> |
||||||
|
{% elif s == "paid" %}<span class="status-badge status-paid">{{ invoice.status }}</span> |
||||||
|
{% elif s == "pending" %}<span class="status-badge status-pending">{{ invoice.status }}</span> |
||||||
|
{% elif s == "overdue" %}<span class="status-badge status-overdue">{{ invoice.status }}</span> |
||||||
|
{% else %}<span class="status-badge status-other">{{ invoice.status }}</span>{% endif %} |
||||||
|
</div> |
||||||
|
<div class="detail-card"><h3>Created</h3><div>{{ invoice.created_at }}</div></div> |
||||||
|
<div class="detail-card"><h3>Total</h3><div>{{ invoice.total_amount }}</div></div> |
||||||
|
<div class="detail-card"><h3>Paid</h3><div>{{ invoice.amount_paid }}</div></div> |
||||||
|
<div class="detail-card"><h3>Outstanding</h3><div>{{ invoice.outstanding }}</div></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoice Items</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Line Total</th></tr></thead> |
||||||
|
<tbody> |
||||||
|
{% for item in items %} |
||||||
|
<tr><td>{{ item.description }}</td><td>{{ item.quantity }}</td><td>{{ item.unit_price }}</td><td>{{ item.line_total }}</td></tr> |
||||||
|
{% else %} |
||||||
|
<tr><td colspan="4">No invoice line items found.</td></tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
|
||||||
|
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %} |
||||||
|
<div class="pay-card"> |
||||||
|
<h3>Pay Now</h3> |
||||||
|
<div class="pay-selector-row"> |
||||||
|
<label for="payMethodSelect"><strong>Choose payment method:</strong></label> |
||||||
|
<select id="payMethodSelect" class="pay-selector"> |
||||||
|
<option value="" {% if not pay_mode %}selected{% endif %}>Select…</option> |
||||||
|
<option value="etransfer" {% if pay_mode == "etransfer" %}selected{% endif %}>e-Transfer</option> |
||||||
|
<option value="square" {% if pay_mode == "square" %}selected{% endif %}>Credit Card</option> |
||||||
|
<option value="crypto" {% if pay_mode == "crypto" %}selected{% endif %}>Crypto</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}"> |
||||||
|
<p><strong>Interac e-Transfer</strong><br>Send payment to:<br>payment@outsidethebox.top<br>Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}"> |
||||||
|
<p><strong>Credit Card (Square)</strong></p> |
||||||
|
<a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-square">Pay with Credit Card</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}"> |
||||||
|
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %} |
||||||
|
<div class="snapshot-wrap"> |
||||||
|
<div class="snapshot-header"> |
||||||
|
<div class="snapshot-meta"> |
||||||
|
<h3 style="margin-top:0;">Crypto Quote Snapshot</h3> |
||||||
|
<div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_at or "—" }}</div> |
||||||
|
<div><strong>Source Status:</strong> {{ invoice.oracle_quote.source_status or "—" }}</div> |
||||||
|
<div><strong>Frozen Amount:</strong> {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}</div> |
||||||
|
{% if pending_crypto_payment %} |
||||||
|
<div style="margin-top:0.75rem;"><strong>Your quote is protected after acceptance.</strong></div> |
||||||
|
{% else %} |
||||||
|
<div style="margin-top:0.75rem;"><strong>Select a crypto asset to accept the quote.</strong></div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="processingTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div> |
||||||
|
<div id="processingTimerLabel" class="snapshot-timer-label">Watching transaction / waiting for confirmation</div> |
||||||
|
</div> |
||||||
|
{% elif pending_crypto_payment %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="lockTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div> |
||||||
|
<div id="lockTimerLabel" class="snapshot-timer-label">Quote protected while you open wallet</div> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="quoteTimerValue" class="snapshot-timer-value" data-expiry="{{ crypto_quote_window_expires_iso }}">--:--</div> |
||||||
|
<div id="quoteTimerLabel" class="snapshot-timer-label">This price times out:</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if pending_crypto_payment and selected_crypto_option %} |
||||||
|
<div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired or pending_crypto_payment.processing_expired %} expired{% endif %}"> |
||||||
|
<div class="lock-grid"> |
||||||
|
<div> |
||||||
|
<h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3> |
||||||
|
<div><strong>Send exactly:</strong> {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}</div> |
||||||
|
<div style="margin-top:0.65rem;"><strong>Destination wallet:</strong></div> |
||||||
|
<code id="walletAddressText" class="lock-code copy-target">{{ pending_crypto_payment.wallet_address }}</code> |
||||||
|
<div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div> |
||||||
|
<code id="invoiceRefText" class="lock-code copy-target">{{ pending_crypto_payment.reference }}</code> |
||||||
|
|
||||||
|
{% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %} |
||||||
|
<div class="wallet-actions"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
id="walletPayButton" |
||||||
|
class="pay-btn pay-btn-wallet" |
||||||
|
data-invoice-id="{{ invoice.id }}" |
||||||
|
data-payment-id="{{ pending_crypto_payment.id }}" |
||||||
|
data-asset="{{ selected_crypto_option.symbol }}" |
||||||
|
data-chain-id="{{ selected_crypto_option.chain_id }}" |
||||||
|
data-asset-type="{{ selected_crypto_option.asset_type }}" |
||||||
|
data-to="{{ selected_crypto_option.wallet_address }}" |
||||||
|
data-amount="{{ pending_crypto_payment.payment_amount }}" |
||||||
|
data-decimals="{{ selected_crypto_option.decimals }}" |
||||||
|
data-token-contract="{{ selected_crypto_option.token_contract or '' }}" |
||||||
|
data-chain-add='{{ (selected_crypto_option.chain_add_params or {})|tojson|safe }}' |
||||||
|
> |
||||||
|
Open MetaMask / Rabby |
||||||
|
</button> |
||||||
|
|
||||||
|
<a |
||||||
|
id="metamaskMobileLink" |
||||||
|
href="#" |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
class="pay-btn pay-btn-mobile" |
||||||
|
data-invoice-id="{{ invoice.id }}" |
||||||
|
> |
||||||
|
Open in MetaMask Mobile |
||||||
|
</a> |
||||||
|
|
||||||
|
<button type="button" id="copyDetailsButton" class="pay-btn pay-btn-copy"> |
||||||
|
Copy Payment Details |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="wallet-help"> |
||||||
|
<h4>Fastest way to pay</h4> |
||||||
|
<p>1. Click <strong>Open MetaMask / Rabby</strong> if your wallet is installed in this browser.</p> |
||||||
|
<p>2. If that does not open your wallet, click <strong>Open in MetaMask Mobile</strong>.</p> |
||||||
|
<p>3. If needed, use <strong>Copy Payment Details</strong> and send manually.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="wallet-note"> |
||||||
|
You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet. |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="copy-row"> |
||||||
|
<span id="walletStatusText"></span> |
||||||
|
<span id="copyStatusText" class="copy-status"></span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% elif pending_crypto_payment.txid %} |
||||||
|
<div style="margin-top:0.9rem;"><strong>Transaction Hash:</strong></div> |
||||||
|
<code class="lock-code mono">{{ pending_crypto_payment.txid }}</code> |
||||||
|
<div style="margin-top:0.75rem;">Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.</div> |
||||||
|
{% elif pending_crypto_payment.lock_expired %} |
||||||
|
<div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto"> |
||||||
|
<table class="quote-table"> |
||||||
|
<thead><tr><th>Asset</th><th>Quoted Amount</th><th>CAD Price</th><th>Status</th><th>Action</th></tr></thead> |
||||||
|
<tbody> |
||||||
|
{% for q in crypto_options %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
{{ q.label }} |
||||||
|
{% if q.recommended %}<span class="quote-badge quote-live">recommended</span>{% endif %} |
||||||
|
{% if q.wallet_capable %}<span class="quote-badge quote-live">wallet</span>{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ q.display_amount or "—" }}</td> |
||||||
|
<td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td> |
||||||
|
<td>{% if q.available %}<span class="quote-badge quote-live">live</span>{% else %}<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>{% endif %}</td> |
||||||
|
<td><button type="submit" name="asset" value="{{ q.symbol }}" class="quote-pick-btn" {% if not q.available %}disabled{% endif %}>Accept {{ q.symbol }}</button></td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</form> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<p>No crypto quote snapshot is available for this invoice yet.</p> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if invoice_payments %} |
||||||
|
<div class="detail-card" style="margin-top:1.25rem;"> |
||||||
|
<h3>Payments Applied</h3> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Method</th> |
||||||
|
<th>Amount</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Received</th> |
||||||
|
<th>Reference / TXID</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for p in invoice_payments %} |
||||||
|
<tr> |
||||||
|
<td>{{ p.payment_method_label }}</td> |
||||||
|
<td>{{ p.payment_amount_display }} {{ p.payment_currency }}</td> |
||||||
|
<td>{{ p.payment_status }}</td> |
||||||
|
<td>{{ p.received_at_local }}</td> |
||||||
|
<td> |
||||||
|
{% if p.txid %} |
||||||
|
{{ p.txid }} |
||||||
|
{% elif p.reference %} |
||||||
|
{{ p.reference }} |
||||||
|
{% else %} |
||||||
|
- |
||||||
|
{% endif %} |
||||||
|
{% if p.wallet_address %}<br><small>{{ p.wallet_address }}</small>{% endif %} |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if pdf_url %} |
||||||
|
<div style="margin-top:1rem;"><a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a></div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
const select = document.getElementById("payMethodSelect"); |
||||||
|
if (select) { |
||||||
|
select.addEventListener("change", function() { |
||||||
|
const value = this.value || ""; |
||||||
|
const url = new URL(window.location.href); |
||||||
|
if (!value) { |
||||||
|
url.searchParams.delete("pay"); |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.delete("refresh_quote"); |
||||||
|
} else { |
||||||
|
url.searchParams.set("pay", value); |
||||||
|
if (value !== "crypto") { |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.delete("refresh_quote"); |
||||||
|
} else { |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.set("refresh_quote", "1"); |
||||||
|
} |
||||||
|
} |
||||||
|
window.location.href = url.toString(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function bindCountdown(valueId, labelId, expireIso, expiredMessage, disableSelector) { |
||||||
|
const valueEl = document.getElementById(valueId); |
||||||
|
const labelEl = document.getElementById(labelId); |
||||||
|
if (!valueEl || !expireIso) return; |
||||||
|
|
||||||
|
function tick() { |
||||||
|
const end = new Date(expireIso).getTime(); |
||||||
|
const now = Date.now(); |
||||||
|
const diff = Math.max(0, Math.floor((end - now) / 1000)); |
||||||
|
|
||||||
|
if (diff <= 0) { |
||||||
|
valueEl.textContent = "00:00"; |
||||||
|
valueEl.classList.add("snapshot-timer-expired"); |
||||||
|
if (labelEl) { |
||||||
|
labelEl.textContent = expiredMessage; |
||||||
|
labelEl.classList.add("snapshot-timer-expired"); |
||||||
|
} |
||||||
|
if (disableSelector) { |
||||||
|
document.querySelectorAll(disableSelector).forEach(btn => btn.disabled = true); |
||||||
|
} |
||||||
|
const lockBox = document.getElementById("lockBox"); |
||||||
|
if (lockBox) lockBox.classList.add("expired"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const m = String(Math.floor(diff / 60)).padStart(2, "0"); |
||||||
|
const s = String(diff % 60).padStart(2, "0"); |
||||||
|
valueEl.textContent = `${m}:${s}`; |
||||||
|
setTimeout(tick, 250); |
||||||
|
} |
||||||
|
|
||||||
|
tick(); |
||||||
|
} |
||||||
|
|
||||||
|
const quoteTimer = document.getElementById("quoteTimerValue"); |
||||||
|
if (quoteTimer && quoteTimer.dataset.expiry) { |
||||||
|
bindCountdown("quoteTimerValue", "quoteTimerLabel", quoteTimer.dataset.expiry, "price has expired - please refresh your view to update", "#cryptoPickForm button"); |
||||||
|
} |
||||||
|
|
||||||
|
const lockTimer = document.getElementById("lockTimerValue"); |
||||||
|
if (lockTimer && lockTimer.dataset.expiry) { |
||||||
|
bindCountdown("lockTimerValue", "lockTimerLabel", lockTimer.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton"); |
||||||
|
} |
||||||
|
|
||||||
|
const processingTimer = document.getElementById("processingTimerValue"); |
||||||
|
if (processingTimer && processingTimer.dataset.expiry) { |
||||||
|
bindCountdown("processingTimerValue", "processingTimerLabel", processingTimer.dataset.expiry, "price has expired - please refresh your quote to update", null); |
||||||
|
} |
||||||
|
|
||||||
|
function toHexBigIntFromDecimal(amountText, decimals) { |
||||||
|
const text = String(amountText || "0"); |
||||||
|
const parts = text.split("."); |
||||||
|
const whole = parts[0] || "0"; |
||||||
|
const frac = (parts[1] || "").padEnd(decimals, "0").slice(0, decimals); |
||||||
|
const combined = (whole + frac).replace(/^0+/, "") || "0"; |
||||||
|
return "0x" + BigInt(combined).toString(16); |
||||||
|
} |
||||||
|
|
||||||
|
function erc20TransferData(to, amountText, decimals) { |
||||||
|
const method = "a9059cbb"; |
||||||
|
const addr = String(to || "").toLowerCase().replace(/^0x/, "").padStart(64, "0"); |
||||||
|
const amtHex = BigInt(toHexBigIntFromDecimal(amountText, decimals)).toString(16).padStart(64, "0"); |
||||||
|
return "0x" + method + addr + amtHex; |
||||||
|
} |
||||||
|
|
||||||
|
async function switchChain(chainId, chainAddParams) { |
||||||
|
const hexChainId = "0x" + Number(chainId).toString(16); |
||||||
|
try { |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_switchEthereumChain", |
||||||
|
params: [{ chainId: hexChainId }] |
||||||
|
}); |
||||||
|
return; |
||||||
|
} catch (err) { |
||||||
|
const code = err && (err.code ?? err?.data?.originalError?.code); |
||||||
|
if ((code === 4902 || String(err).includes("4902")) && chainAddParams) { |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_addEthereumChain", |
||||||
|
params: [chainAddParams] |
||||||
|
}); |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_switchEthereumChain", |
||||||
|
params: [{ chainId: hexChainId }] |
||||||
|
}); |
||||||
|
return; |
||||||
|
} |
||||||
|
throw err; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function buildMetaMaskMobileLink() { |
||||||
|
const currentUrl = window.location.href; |
||||||
|
return "https://link.metamask.io/dapp/" + currentUrl.replace(/^https?:\/\//, ""); |
||||||
|
} |
||||||
|
|
||||||
|
const mmLink = document.getElementById("metamaskMobileLink"); |
||||||
|
if (mmLink) { |
||||||
|
mmLink.href = buildMetaMaskMobileLink(); |
||||||
|
} |
||||||
|
|
||||||
|
async function copyText(text) { |
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) { |
||||||
|
await navigator.clipboard.writeText(text); |
||||||
|
return; |
||||||
|
} |
||||||
|
const ta = document.createElement("textarea"); |
||||||
|
ta.value = text; |
||||||
|
document.body.appendChild(ta); |
||||||
|
ta.select(); |
||||||
|
document.execCommand("copy"); |
||||||
|
ta.remove(); |
||||||
|
} |
||||||
|
|
||||||
|
const copyBtn = document.getElementById("copyDetailsButton"); |
||||||
|
if (copyBtn) { |
||||||
|
copyBtn.addEventListener("click", async function() { |
||||||
|
const copyStatus = document.getElementById("copyStatusText"); |
||||||
|
const walletAddress = document.getElementById("walletAddressText")?.textContent || ""; |
||||||
|
const invoiceRef = document.getElementById("invoiceRefText")?.textContent || ""; |
||||||
|
const amount = document.getElementById("walletPayButton")?.dataset.amount || ""; |
||||||
|
const asset = document.getElementById("walletPayButton")?.dataset.asset || ""; |
||||||
|
|
||||||
|
const payload = |
||||||
|
`Asset: ${asset} |
||||||
|
Amount: ${amount} ${asset} |
||||||
|
Wallet: ${walletAddress} |
||||||
|
Reference: ${invoiceRef}`; |
||||||
|
|
||||||
|
try { |
||||||
|
await copyText(payload); |
||||||
|
if (copyStatus) copyStatus.textContent = "Payment details copied."; |
||||||
|
} catch (err) { |
||||||
|
if (copyStatus) copyStatus.textContent = "Copy failed."; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const walletButton = document.getElementById("walletPayButton"); |
||||||
|
|
||||||
|
function pendingTxStorageKey(invoiceId, paymentId) { |
||||||
|
return `otb_pending_tx_${invoiceId}_${paymentId}`; |
||||||
|
} |
||||||
|
|
||||||
|
async function submitTxHash(invoiceId, paymentId, asset, txHash) { |
||||||
|
const res = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { |
||||||
|
method: "POST", |
||||||
|
headers: { |
||||||
|
"Content-Type": "application/json", |
||||||
|
"Accept": "application/json" |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
payment_id: paymentId, |
||||||
|
asset: asset, |
||||||
|
tx_hash: txHash |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
let data = {}; |
||||||
|
try { |
||||||
|
data = await res.json(); |
||||||
|
} catch (err) { |
||||||
|
data = { ok: false, error: "invalid_json_response" }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!res.ok || !data.ok) { |
||||||
|
throw new Error(data.error || `submit_failed_http_${res.status}`); |
||||||
|
} |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
async function tryRecoverPendingTxFromStorage() { |
||||||
|
const invoiceId = "{{ invoice.id }}"; |
||||||
|
const paymentId = "{{ pending_crypto_payment.id if pending_crypto_payment else '' }}"; |
||||||
|
const asset = "{{ selected_crypto_option.symbol if selected_crypto_option else '' }}"; |
||||||
|
|
||||||
|
if (!invoiceId || !paymentId || !asset) return; |
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||||
|
return; |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
const key = pendingTxStorageKey(invoiceId, paymentId); |
||||||
|
const savedTx = localStorage.getItem(key); |
||||||
|
if (!savedTx || !savedTx.startsWith("0x")) return; |
||||||
|
|
||||||
|
const walletStatus = document.getElementById("walletStatusText"); |
||||||
|
try { |
||||||
|
if (walletStatus) walletStatus.textContent = "Retrying saved transaction submission..."; |
||||||
|
await submitTxHash(invoiceId, paymentId, asset, savedTx); |
||||||
|
localStorage.removeItem(key); |
||||||
|
const url = new URL(window.location.href); |
||||||
|
url.searchParams.set("pay", "crypto"); |
||||||
|
url.searchParams.set("asset", asset); |
||||||
|
url.searchParams.set("payment_id", paymentId); |
||||||
|
window.location.href = url.toString(); |
||||||
|
} catch (err) { |
||||||
|
if (walletStatus) walletStatus.textContent = `Saved tx retry failed: ${err.message}`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (walletButton) { |
||||||
|
walletButton.addEventListener("click", async function() { |
||||||
|
const walletStatus = document.getElementById("walletStatusText"); |
||||||
|
const invoiceId = this.dataset.invoiceId; |
||||||
|
const paymentId = this.dataset.paymentId; |
||||||
|
const asset = this.dataset.asset; |
||||||
|
const chainId = this.dataset.chainId; |
||||||
|
const assetType = this.dataset.assetType; |
||||||
|
const to = this.dataset.to; |
||||||
|
const amount = this.dataset.amount; |
||||||
|
const decimals = Number(this.dataset.decimals || "18"); |
||||||
|
const tokenContract = this.dataset.tokenContract || ""; |
||||||
|
let chainAddParams = null; |
||||||
|
try { |
||||||
|
chainAddParams = this.dataset.chainAdd ? JSON.parse(this.dataset.chainAdd) : null; |
||||||
|
} catch (err) { |
||||||
|
chainAddParams = null; |
||||||
|
} |
||||||
|
|
||||||
|
const setStatus = (msg) => { |
||||||
|
if (walletStatus) walletStatus.textContent = msg; |
||||||
|
}; |
||||||
|
|
||||||
|
if (!window.ethereum || !window.ethereum.request) { |
||||||
|
setStatus("No browser wallet detected. Use MetaMask/Rabby or MetaMask Mobile."); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
this.disabled = true; |
||||||
|
setStatus("Opening wallet..."); |
||||||
|
|
||||||
|
await window.ethereum.request({ method: "eth_requestAccounts" }); |
||||||
|
|
||||||
|
if (chainId && chainId !== "None" && chainId !== "") { |
||||||
|
try { |
||||||
|
await switchChain(Number(chainId), chainAddParams); |
||||||
|
} catch (err) { |
||||||
|
setStatus(`Chain switch failed: ${err.message || err}`); |
||||||
|
this.disabled = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let txParams; |
||||||
|
if (assetType === "token" && tokenContract) { |
||||||
|
txParams = { |
||||||
|
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||||
|
to: tokenContract, |
||||||
|
data: erc20TransferData(to, amount, decimals), |
||||||
|
value: "0x0" |
||||||
|
}; |
||||||
|
} else { |
||||||
|
txParams = { |
||||||
|
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||||
|
to: to, |
||||||
|
value: toHexBigIntFromDecimal(amount, decimals) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
setStatus("Waiting for wallet confirmation..."); |
||||||
|
const txHash = await window.ethereum.request({ |
||||||
|
method: "eth_sendTransaction", |
||||||
|
params: [txParams] |
||||||
|
}); |
||||||
|
|
||||||
|
if (!txHash || !String(txHash).startsWith("0x")) { |
||||||
|
throw new Error("wallet did not return a tx hash"); |
||||||
|
} |
||||||
|
|
||||||
|
const storageKey = pendingTxStorageKey(invoiceId, paymentId); |
||||||
|
localStorage.setItem(storageKey, String(txHash)); |
||||||
|
|
||||||
|
setStatus(`Wallet submitted tx: ${txHash}. Sending to billing server...`); |
||||||
|
|
||||||
|
await submitTxHash(invoiceId, paymentId, asset, txHash); |
||||||
|
|
||||||
|
localStorage.removeItem(storageKey); |
||||||
|
|
||||||
|
setStatus("Transaction submitted. Reloading into processing view..."); |
||||||
|
const url = new URL(window.location.href); |
||||||
|
url.searchParams.set("pay", "crypto"); |
||||||
|
url.searchParams.set("asset", asset); |
||||||
|
url.searchParams.set("payment_id", paymentId); |
||||||
|
window.location.href = url.toString(); |
||||||
|
} catch (err) { |
||||||
|
setStatus(`Wallet submit failed: ${err.message || err}`); |
||||||
|
this.disabled = false; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
tryRecoverPendingTxFromStorage(); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
const processingAutoRefreshEnabled = {{ 'true' if pending_crypto_payment and pending_crypto_payment.txid and (invoice.status or '')|lower != 'paid' else 'false' }}; |
||||||
|
if (processingAutoRefreshEnabled) { |
||||||
|
setTimeout(function() { |
||||||
|
window.location.reload(); |
||||||
|
}, 10000); |
||||||
|
} |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,96 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-note { margin-top: 1rem; opacity: 0.88; font-size: 0.95rem; } |
||||||
|
.portal-links { margin-top: 1rem; } |
||||||
|
.portal-links a { margin-right: 1rem; } |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="https://outsidethebox.top/">Home</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,76 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Set Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Create Your Portal Password</h1> |
||||||
|
<p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/set-password"> |
||||||
|
<div> |
||||||
|
<label for="password">New Password</label> |
||||||
|
<input id="password" name="password" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label for="password2">Confirm Password</label> |
||||||
|
<input id="password2" name="password2" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<button class="portal-btn" type="submit">Set Password</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,168 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { |
||||||
|
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
.portal-actions a { |
||||||
|
margin-left: 0.75rem; |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
.summary-grid { |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
||||||
|
gap:1rem; |
||||||
|
margin: 1rem 0 1.25rem 0; |
||||||
|
} |
||||||
|
.summary-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
} |
||||||
|
.summary-card h3 { margin-top:0; margin-bottom:0.4rem; } |
||||||
|
table.portal-table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
table.portal-table th, table.portal-table td { |
||||||
|
padding: 0.8rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.12); |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
table.portal-table th { |
||||||
|
background: #e9eef7; |
||||||
|
color: #10203f; |
||||||
|
} |
||||||
|
.invoice-link { |
||||||
|
color: inherit; |
||||||
|
text-decoration: underline; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
.status-badge { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.18rem 0.55rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.86rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
.status-paid { |
||||||
|
background: rgba(34, 197, 94, 0.18); |
||||||
|
color: #4ade80; |
||||||
|
} |
||||||
|
.status-pending { |
||||||
|
background: rgba(245, 158, 11, 0.20); |
||||||
|
color: #fbbf24; |
||||||
|
} |
||||||
|
.status-overdue { |
||||||
|
background: rgba(239, 68, 68, 0.18); |
||||||
|
color: #f87171; |
||||||
|
} |
||||||
|
.status-other { |
||||||
|
background: rgba(148, 163, 184, 0.20); |
||||||
|
color: #cbd5e1; |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Client Dashboard</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/invoices/download-all">Download All Invoices (ZIP)</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div>{{ invoice_count }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div>{{ total_outstanding }}</div> |
||||||
|
</div> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div>{{ total_paid }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoices</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { |
||||||
|
window.location.reload(); |
||||||
|
}, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,88 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Forgot Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Reset Portal Password</h1> |
||||||
|
<p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> |
||||||
|
|
||||||
|
{% if error %} |
||||||
|
<div class="portal-msg">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if message %} |
||||||
|
<div class="portal-msg">{{ message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/forgot-password"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" value="{{ form_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Send Reset Code</button> |
||||||
|
<a class="portal-btn" href="/portal">Back to Portal Login</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,733 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Invoice Detail - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
||||||
|
.portal-top { display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom: 1rem; } |
||||||
|
.portal-actions a { margin-left: 0.75rem; text-decoration: underline; } |
||||||
|
.detail-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin: 1rem 0 1.25rem 0; } |
||||||
|
.detail-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-bottom: 1rem; } |
||||||
|
.detail-card h3 { margin-top: 0; margin-bottom: 0.4rem; } |
||||||
|
table.portal-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } |
||||||
|
table.portal-table th, table.portal-table td { padding: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; } |
||||||
|
table.portal-table th { background: #e9eef7; color: #10203f; } |
||||||
|
.status-badge { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.86rem; font-weight: 700; } |
||||||
|
.status-paid { background: rgba(34, 197, 94, 0.18); color: #4ade80; } |
||||||
|
.status-pending { background: rgba(245, 158, 11, 0.20); color: #fbbf24; } |
||||||
|
.status-overdue { background: rgba(239, 68, 68, 0.18); color: #f87171; } |
||||||
|
.status-other { background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } |
||||||
|
|
||||||
|
.pay-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-top: 1.25rem; } |
||||||
|
.pay-selector-row { display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; margin-top:0.75rem; } |
||||||
|
.pay-selector { padding: 10px 12px; min-width: 220px; border-radius: 8px; } |
||||||
|
.pay-panel { margin-top: 1rem; padding: 1rem; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(255,255,255,0.02); } |
||||||
|
.pay-panel.hidden { display: none; } |
||||||
|
|
||||||
|
.pay-btn { |
||||||
|
display:inline-block; |
||||||
|
padding:12px 18px; |
||||||
|
color:#ffffff; |
||||||
|
text-decoration:none; |
||||||
|
border-radius:8px; |
||||||
|
font-weight:700; |
||||||
|
border:none; |
||||||
|
cursor:pointer; |
||||||
|
margin:8px 0 0 0; |
||||||
|
} |
||||||
|
.pay-btn-square { background:#16a34a; } |
||||||
|
.pay-btn-wallet { background:#2563eb; } |
||||||
|
.pay-btn-mobile { background:#7c3aed; } |
||||||
|
.pay-btn-copy { background:#374151; } |
||||||
|
|
||||||
|
.error-box { border: 1px solid rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); color: #fecaca; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } |
||||||
|
.success-box { border: 1px solid rgba(34, 197, 94, 0.55); background: rgba(22, 101, 52, 0.18); color: #dcfce7; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } |
||||||
|
|
||||||
|
.snapshot-wrap { position: relative; margin-top: 1rem; border: 1px solid rgba(255,255,255,0.14); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.02); } |
||||||
|
.snapshot-header { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; } |
||||||
|
.snapshot-meta { flex: 1 1 auto; min-width: 0; line-height: 1.65; } |
||||||
|
.snapshot-timer-box { |
||||||
|
width: 220px; |
||||||
|
min-height: 132px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
background: rgba(0,0,0,0.18); |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
justify-content:center; |
||||||
|
align-items:center; |
||||||
|
text-align:center; |
||||||
|
padding: 0.9rem; |
||||||
|
} |
||||||
|
.snapshot-timer-value { font-size: 2rem; font-weight: 800; line-height: 1.1; } |
||||||
|
.snapshot-timer-label { margin-top: 0.55rem; font-size: 0.95rem; opacity: 0.95; } |
||||||
|
.snapshot-timer-expired { color: #f87171; } |
||||||
|
|
||||||
|
.quote-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } |
||||||
|
.quote-table th, .quote-table td { padding: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; vertical-align: top; } |
||||||
|
.quote-table th { background: #e9eef7; color: #10203f; } |
||||||
|
.quote-badge { display: inline-block; padding: 0.14rem 0.48rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; margin-left: 0.4rem; } |
||||||
|
.quote-live { background: rgba(34, 197, 94, 0.18); color: #4ade80; } |
||||||
|
.quote-stale { background: rgba(239, 68, 68, 0.18); color: #f87171; } |
||||||
|
.quote-pick-btn { padding: 8px 12px; border-radius: 8px; border: none; background: #2563eb; color: #fff; font-weight: 700; cursor: pointer; } |
||||||
|
.quote-pick-btn[disabled] { opacity: 0.5; cursor: not-allowed; } |
||||||
|
|
||||||
|
.lock-box { margin-top: 1rem; border: 1px solid rgba(34, 197, 94, 0.28); background: rgba(22, 101, 52, 0.16); border-radius: 12px; padding: 1rem; } |
||||||
|
.lock-box.expired { border-color: rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); } |
||||||
|
.lock-grid { display:grid; grid-template-columns: 1fr 220px; gap:1rem; align-items:start; } |
||||||
|
.lock-code { display:block; margin-top:0.35rem; padding:0.65rem 0.8rem; background: rgba(0,0,0,0.22); border-radius: 8px; overflow-wrap:anywhere; } |
||||||
|
|
||||||
|
.wallet-actions { |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.9rem; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
.wallet-help { |
||||||
|
margin-top: 0.85rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
border: 1px solid rgba(255,255,255,0.10); |
||||||
|
} |
||||||
|
.wallet-help h4 { |
||||||
|
margin: 0 0 0.55rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
.wallet-help p { |
||||||
|
margin: 0.35rem 0; |
||||||
|
} |
||||||
|
.wallet-note { opacity:0.9; margin-top:0.65rem; } |
||||||
|
.mono { font-family: monospace; } |
||||||
|
.copy-row { |
||||||
|
display:flex; |
||||||
|
gap:0.5rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
margin-top:0.65rem; |
||||||
|
} |
||||||
|
.copy-target { |
||||||
|
flex: 1 1 420px; |
||||||
|
min-width: 220px; |
||||||
|
} |
||||||
|
.copy-status { |
||||||
|
display:inline-block; |
||||||
|
margin-left: 0.5rem; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 820px) { |
||||||
|
.snapshot-header, .lock-grid { grid-template-columns: 1fr; display:block; } |
||||||
|
.snapshot-timer-box { width: 100%; margin-top: 1rem; min-height: 110px; } |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-top"> |
||||||
|
<div> |
||||||
|
<h1>Invoice Detail</h1> |
||||||
|
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
</div> |
||||||
|
<div class="portal-actions"> |
||||||
|
<a href="/portal/dashboard">Back to Dashboard</a> |
||||||
|
<a href="https://outsidethebox.top/">Home</a> |
||||||
|
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
<a href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if (invoice.status or "")|lower == "paid" %} |
||||||
|
<div class="success-box">✓ This invoice has been paid. Thank you!</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if crypto_error %} |
||||||
|
<div class="error-box">{{ crypto_error }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<div class="detail-grid"> |
||||||
|
<div class="detail-card"><h3>Invoice</h3><div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div></div> |
||||||
|
<div class="detail-card"> |
||||||
|
<h3>Status</h3> |
||||||
|
{% set s = (invoice.status or "")|lower %} |
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} |
||||||
|
<span class="status-badge status-pending">processing</span> |
||||||
|
{% elif s == "paid" %}<span class="status-badge status-paid">{{ invoice.status }}</span> |
||||||
|
{% elif s == "pending" %}<span class="status-badge status-pending">{{ invoice.status }}</span> |
||||||
|
{% elif s == "overdue" %}<span class="status-badge status-overdue">{{ invoice.status }}</span> |
||||||
|
{% else %}<span class="status-badge status-other">{{ invoice.status }}</span>{% endif %} |
||||||
|
</div> |
||||||
|
<div class="detail-card"><h3>Created</h3><div>{{ invoice.created_at }}</div></div> |
||||||
|
<div class="detail-card"><h3>Total</h3><div>{{ invoice.total_amount }}</div></div> |
||||||
|
<div class="detail-card"><h3>Paid</h3><div>{{ invoice.amount_paid }}</div></div> |
||||||
|
<div class="detail-card"><h3>Outstanding</h3><div>{{ invoice.outstanding }}</div></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2>Invoice Items</h2> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Line Total</th></tr></thead> |
||||||
|
<tbody> |
||||||
|
{% for item in items %} |
||||||
|
<tr><td>{{ item.description }}</td><td>{{ item.quantity }}</td><td>{{ item.unit_price }}</td><td>{{ item.line_total }}</td></tr> |
||||||
|
{% else %} |
||||||
|
<tr><td colspan="4">No invoice line items found.</td></tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
|
||||||
|
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %} |
||||||
|
<div class="pay-card"> |
||||||
|
<h3>Pay Now</h3> |
||||||
|
<div class="pay-selector-row"> |
||||||
|
<label for="payMethodSelect"><strong>Choose payment method:</strong></label> |
||||||
|
<select id="payMethodSelect" class="pay-selector"> |
||||||
|
<option value="" {% if not pay_mode %}selected{% endif %}>Select…</option> |
||||||
|
<option value="etransfer" {% if pay_mode == "etransfer" %}selected{% endif %}>e-Transfer</option> |
||||||
|
<option value="square" {% if pay_mode == "square" %}selected{% endif %}>Credit Card</option> |
||||||
|
<option value="crypto" {% if pay_mode == "crypto" %}selected{% endif %}>Crypto</option> |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}"> |
||||||
|
<p><strong>Interac e-Transfer</strong><br>Send payment to:<br>payment@outsidethebox.top<br>Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}"> |
||||||
|
<p><strong>Credit Card (Square)</strong></p> |
||||||
|
<a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-square">Pay with Credit Card</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}"> |
||||||
|
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %} |
||||||
|
<div class="snapshot-wrap"> |
||||||
|
<div class="snapshot-header"> |
||||||
|
<div class="snapshot-meta"> |
||||||
|
<h3 style="margin-top:0;">Crypto Quote Snapshot</h3> |
||||||
|
<div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_at or "—" }}</div> |
||||||
|
<div><strong>Source Status:</strong> {{ invoice.oracle_quote.source_status or "—" }}</div> |
||||||
|
<div><strong>Frozen Amount:</strong> {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}</div> |
||||||
|
{% if pending_crypto_payment %} |
||||||
|
<div style="margin-top:0.75rem;"><strong>Your quote is protected after acceptance.</strong></div> |
||||||
|
{% else %} |
||||||
|
<div style="margin-top:0.75rem;"><strong>Select a crypto asset to accept the quote.</strong></div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="processingTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div> |
||||||
|
<div id="processingTimerLabel" class="snapshot-timer-label">Watching transaction / waiting for confirmation</div> |
||||||
|
</div> |
||||||
|
{% elif pending_crypto_payment %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="lockTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div> |
||||||
|
<div id="lockTimerLabel" class="snapshot-timer-label">Quote protected while you open wallet</div> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<div class="snapshot-timer-box"> |
||||||
|
<div id="quoteTimerValue" class="snapshot-timer-value" data-expiry="{{ crypto_quote_window_expires_iso }}">--:--</div> |
||||||
|
<div id="quoteTimerLabel" class="snapshot-timer-label">This price times out:</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if pending_crypto_payment and selected_crypto_option %} |
||||||
|
<div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired or pending_crypto_payment.processing_expired %} expired{% endif %}"> |
||||||
|
<div class="lock-grid"> |
||||||
|
<div> |
||||||
|
<h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3> |
||||||
|
<div><strong>Send exactly:</strong> {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}</div> |
||||||
|
<div style="margin-top:0.65rem;"><strong>Destination wallet:</strong></div> |
||||||
|
<code id="walletAddressText" class="lock-code copy-target">{{ pending_crypto_payment.wallet_address }}</code> |
||||||
|
<div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div> |
||||||
|
<code id="invoiceRefText" class="lock-code copy-target">{{ pending_crypto_payment.reference }}</code> |
||||||
|
|
||||||
|
{% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %} |
||||||
|
<div class="wallet-actions"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
id="walletPayButton" |
||||||
|
class="pay-btn pay-btn-wallet" |
||||||
|
data-invoice-id="{{ invoice.id }}" |
||||||
|
data-payment-id="{{ pending_crypto_payment.id }}" |
||||||
|
data-asset="{{ selected_crypto_option.symbol }}" |
||||||
|
data-chain-id="{{ selected_crypto_option.chain_id }}" |
||||||
|
data-asset-type="{{ selected_crypto_option.asset_type }}" |
||||||
|
data-to="{{ selected_crypto_option.wallet_address }}" |
||||||
|
data-amount="{{ pending_crypto_payment.payment_amount }}" |
||||||
|
data-decimals="{{ selected_crypto_option.decimals }}" |
||||||
|
data-token-contract="{{ selected_crypto_option.token_contract or '' }}" |
||||||
|
data-chain-add='{{ (selected_crypto_option.chain_add_params or {})|tojson|safe }}' |
||||||
|
> |
||||||
|
Open MetaMask / Rabby |
||||||
|
</button> |
||||||
|
|
||||||
|
<a |
||||||
|
id="metamaskMobileLink" |
||||||
|
href="#" |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
class="pay-btn pay-btn-mobile" |
||||||
|
data-invoice-id="{{ invoice.id }}" |
||||||
|
> |
||||||
|
Open in MetaMask Mobile |
||||||
|
</a> |
||||||
|
|
||||||
|
<button type="button" id="copyDetailsButton" class="pay-btn pay-btn-copy"> |
||||||
|
Copy Payment Details |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="wallet-help"> |
||||||
|
<h4>Fastest way to pay</h4> |
||||||
|
<p>1. Click <strong>Open MetaMask / Rabby</strong> if your wallet is installed in this browser.</p> |
||||||
|
<p>2. If that does not open your wallet, click <strong>Open in MetaMask Mobile</strong>.</p> |
||||||
|
<p>3. If needed, use <strong>Copy Payment Details</strong> and send manually.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="wallet-note"> |
||||||
|
You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet. |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="copy-row"> |
||||||
|
<span id="walletStatusText"></span> |
||||||
|
<span id="copyStatusText" class="copy-status"></span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% elif pending_crypto_payment.txid %} |
||||||
|
<div style="margin-top:0.9rem;"><strong>Transaction Hash:</strong></div> |
||||||
|
<code class="lock-code mono">{{ pending_crypto_payment.txid }}</code> |
||||||
|
<div style="margin-top:0.75rem;">Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.</div> |
||||||
|
{% elif pending_crypto_payment.lock_expired %} |
||||||
|
<div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto"> |
||||||
|
<table class="quote-table"> |
||||||
|
<thead><tr><th>Asset</th><th>Quoted Amount</th><th>CAD Price</th><th>Status</th><th>Action</th></tr></thead> |
||||||
|
<tbody> |
||||||
|
{% for q in crypto_options %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
{{ q.label }} |
||||||
|
{% if q.recommended %}<span class="quote-badge quote-live">recommended</span>{% endif %} |
||||||
|
{% if q.wallet_capable %}<span class="quote-badge quote-live">wallet</span>{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ q.display_amount or "—" }}</td> |
||||||
|
<td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td> |
||||||
|
<td>{% if q.available %}<span class="quote-badge quote-live">live</span>{% else %}<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>{% endif %}</td> |
||||||
|
<td><button type="submit" name="asset" value="{{ q.symbol }}" class="quote-pick-btn" {% if not q.available %}disabled{% endif %}>Accept {{ q.symbol }}</button></td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</form> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<p>No crypto quote snapshot is available for this invoice yet.</p> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if invoice_payments %} |
||||||
|
<div class="detail-card" style="margin-top:1.25rem;"> |
||||||
|
<h3>Payments Applied</h3> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Method</th> |
||||||
|
<th>Amount</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Received</th> |
||||||
|
<th>Reference / TXID</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for p in invoice_payments %} |
||||||
|
<tr> |
||||||
|
<td>{{ p.payment_method_label }}</td> |
||||||
|
<td>{{ p.payment_amount_display }} {{ p.payment_currency }}</td> |
||||||
|
<td>{{ p.payment_status }}</td> |
||||||
|
<td>{{ p.received_at_local }}</td> |
||||||
|
<td> |
||||||
|
{% if p.txid %} |
||||||
|
{{ p.txid }} |
||||||
|
{% elif p.reference %} |
||||||
|
{{ p.reference }} |
||||||
|
{% else %} |
||||||
|
- |
||||||
|
{% endif %} |
||||||
|
{% if p.wallet_address %}<br><small>{{ p.wallet_address }}</small>{% endif %} |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
{% if pdf_url %} |
||||||
|
<div style="margin-top:1rem;"><a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a></div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
const select = document.getElementById("payMethodSelect"); |
||||||
|
if (select) { |
||||||
|
select.addEventListener("change", function() { |
||||||
|
const value = this.value || ""; |
||||||
|
const url = new URL(window.location.href); |
||||||
|
if (!value) { |
||||||
|
url.searchParams.delete("pay"); |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.delete("refresh_quote"); |
||||||
|
} else { |
||||||
|
url.searchParams.set("pay", value); |
||||||
|
if (value !== "crypto") { |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.delete("refresh_quote"); |
||||||
|
} else { |
||||||
|
url.searchParams.delete("asset"); |
||||||
|
url.searchParams.delete("payment_id"); |
||||||
|
url.searchParams.delete("crypto_error"); |
||||||
|
url.searchParams.set("refresh_quote", "1"); |
||||||
|
} |
||||||
|
} |
||||||
|
window.location.href = url.toString(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function bindCountdown(valueId, labelId, expireIso, expiredMessage, disableSelector) { |
||||||
|
const valueEl = document.getElementById(valueId); |
||||||
|
const labelEl = document.getElementById(labelId); |
||||||
|
if (!valueEl || !expireIso) return; |
||||||
|
|
||||||
|
function tick() { |
||||||
|
const end = new Date(expireIso).getTime(); |
||||||
|
const now = Date.now(); |
||||||
|
const diff = Math.max(0, Math.floor((end - now) / 1000)); |
||||||
|
|
||||||
|
if (diff <= 0) { |
||||||
|
valueEl.textContent = "00:00"; |
||||||
|
valueEl.classList.add("snapshot-timer-expired"); |
||||||
|
if (labelEl) { |
||||||
|
labelEl.textContent = expiredMessage; |
||||||
|
labelEl.classList.add("snapshot-timer-expired"); |
||||||
|
} |
||||||
|
if (disableSelector) { |
||||||
|
document.querySelectorAll(disableSelector).forEach(btn => btn.disabled = true); |
||||||
|
} |
||||||
|
const lockBox = document.getElementById("lockBox"); |
||||||
|
if (lockBox) lockBox.classList.add("expired"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const m = String(Math.floor(diff / 60)).padStart(2, "0"); |
||||||
|
const s = String(diff % 60).padStart(2, "0"); |
||||||
|
valueEl.textContent = `${m}:${s}`; |
||||||
|
setTimeout(tick, 250); |
||||||
|
} |
||||||
|
|
||||||
|
tick(); |
||||||
|
} |
||||||
|
|
||||||
|
const quoteTimer = document.getElementById("quoteTimerValue"); |
||||||
|
if (quoteTimer && quoteTimer.dataset.expiry) { |
||||||
|
bindCountdown("quoteTimerValue", "quoteTimerLabel", quoteTimer.dataset.expiry, "price has expired - please refresh your view to update", "#cryptoPickForm button"); |
||||||
|
} |
||||||
|
|
||||||
|
const lockTimer = document.getElementById("lockTimerValue"); |
||||||
|
if (lockTimer && lockTimer.dataset.expiry) { |
||||||
|
bindCountdown("lockTimerValue", "lockTimerLabel", lockTimer.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton"); |
||||||
|
} |
||||||
|
|
||||||
|
const processingTimer = document.getElementById("processingTimerValue"); |
||||||
|
if (processingTimer && processingTimer.dataset.expiry) { |
||||||
|
bindCountdown("processingTimerValue", "processingTimerLabel", processingTimer.dataset.expiry, "price has expired - please refresh your quote to update", null); |
||||||
|
} |
||||||
|
|
||||||
|
function toHexBigIntFromDecimal(amountText, decimals) { |
||||||
|
const text = String(amountText || "0"); |
||||||
|
const parts = text.split("."); |
||||||
|
const whole = parts[0] || "0"; |
||||||
|
const frac = (parts[1] || "").padEnd(decimals, "0").slice(0, decimals); |
||||||
|
const combined = (whole + frac).replace(/^0+/, "") || "0"; |
||||||
|
return "0x" + BigInt(combined).toString(16); |
||||||
|
} |
||||||
|
|
||||||
|
function erc20TransferData(to, amountText, decimals) { |
||||||
|
const method = "a9059cbb"; |
||||||
|
const addr = String(to || "").toLowerCase().replace(/^0x/, "").padStart(64, "0"); |
||||||
|
const amtHex = BigInt(toHexBigIntFromDecimal(amountText, decimals)).toString(16).padStart(64, "0"); |
||||||
|
return "0x" + method + addr + amtHex; |
||||||
|
} |
||||||
|
|
||||||
|
async function switchChain(chainId, chainAddParams) { |
||||||
|
const hexChainId = "0x" + Number(chainId).toString(16); |
||||||
|
try { |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_switchEthereumChain", |
||||||
|
params: [{ chainId: hexChainId }] |
||||||
|
}); |
||||||
|
return; |
||||||
|
} catch (err) { |
||||||
|
const code = err && (err.code ?? err?.data?.originalError?.code); |
||||||
|
if ((code === 4902 || String(err).includes("4902")) && chainAddParams) { |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_addEthereumChain", |
||||||
|
params: [chainAddParams] |
||||||
|
}); |
||||||
|
await window.ethereum.request({ |
||||||
|
method: "wallet_switchEthereumChain", |
||||||
|
params: [{ chainId: hexChainId }] |
||||||
|
}); |
||||||
|
return; |
||||||
|
} |
||||||
|
throw err; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function buildMetaMaskMobileLink() { |
||||||
|
const currentUrl = window.location.href; |
||||||
|
return "https://link.metamask.io/dapp/" + currentUrl.replace(/^https?:\/\//, ""); |
||||||
|
} |
||||||
|
|
||||||
|
const mmLink = document.getElementById("metamaskMobileLink"); |
||||||
|
if (mmLink) { |
||||||
|
mmLink.href = buildMetaMaskMobileLink(); |
||||||
|
} |
||||||
|
|
||||||
|
async function copyText(text) { |
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) { |
||||||
|
await navigator.clipboard.writeText(text); |
||||||
|
return; |
||||||
|
} |
||||||
|
const ta = document.createElement("textarea"); |
||||||
|
ta.value = text; |
||||||
|
document.body.appendChild(ta); |
||||||
|
ta.select(); |
||||||
|
document.execCommand("copy"); |
||||||
|
ta.remove(); |
||||||
|
} |
||||||
|
|
||||||
|
const copyBtn = document.getElementById("copyDetailsButton"); |
||||||
|
if (copyBtn) { |
||||||
|
copyBtn.addEventListener("click", async function() { |
||||||
|
const copyStatus = document.getElementById("copyStatusText"); |
||||||
|
const walletAddress = document.getElementById("walletAddressText")?.textContent || ""; |
||||||
|
const invoiceRef = document.getElementById("invoiceRefText")?.textContent || ""; |
||||||
|
const amount = document.getElementById("walletPayButton")?.dataset.amount || ""; |
||||||
|
const asset = document.getElementById("walletPayButton")?.dataset.asset || ""; |
||||||
|
|
||||||
|
const payload = |
||||||
|
`Asset: ${asset} |
||||||
|
Amount: ${amount} ${asset} |
||||||
|
Wallet: ${walletAddress} |
||||||
|
Reference: ${invoiceRef}`; |
||||||
|
|
||||||
|
try { |
||||||
|
await copyText(payload); |
||||||
|
if (copyStatus) copyStatus.textContent = "Payment details copied."; |
||||||
|
} catch (err) { |
||||||
|
if (copyStatus) copyStatus.textContent = "Copy failed."; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const walletButton = document.getElementById("walletPayButton"); |
||||||
|
|
||||||
|
function pendingTxStorageKey(invoiceId, paymentId) { |
||||||
|
return `otb_pending_tx_${invoiceId}_${paymentId}`; |
||||||
|
} |
||||||
|
|
||||||
|
async function submitTxHash(invoiceId, paymentId, asset, txHash) { |
||||||
|
const res = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { |
||||||
|
method: "POST", |
||||||
|
headers: { |
||||||
|
"Content-Type": "application/json", |
||||||
|
"Accept": "application/json" |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
payment_id: paymentId, |
||||||
|
asset: asset, |
||||||
|
tx_hash: txHash |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
let data = {}; |
||||||
|
try { |
||||||
|
data = await res.json(); |
||||||
|
} catch (err) { |
||||||
|
data = { ok: false, error: "invalid_json_response" }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!res.ok || !data.ok) { |
||||||
|
throw new Error(data.error || `submit_failed_http_${res.status}`); |
||||||
|
} |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
async function tryRecoverPendingTxFromStorage() { |
||||||
|
const invoiceId = "{{ invoice.id }}"; |
||||||
|
const paymentId = "{{ pending_crypto_payment.id if pending_crypto_payment else '' }}"; |
||||||
|
const asset = "{{ selected_crypto_option.symbol if selected_crypto_option else '' }}"; |
||||||
|
|
||||||
|
if (!invoiceId || !paymentId || !asset) return; |
||||||
|
{% if pending_crypto_payment and pending_crypto_payment.txid %} |
||||||
|
return; |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
const key = pendingTxStorageKey(invoiceId, paymentId); |
||||||
|
const savedTx = localStorage.getItem(key); |
||||||
|
if (!savedTx || !savedTx.startsWith("0x")) return; |
||||||
|
|
||||||
|
const walletStatus = document.getElementById("walletStatusText"); |
||||||
|
try { |
||||||
|
if (walletStatus) walletStatus.textContent = "Retrying saved transaction submission..."; |
||||||
|
await submitTxHash(invoiceId, paymentId, asset, savedTx); |
||||||
|
localStorage.removeItem(key); |
||||||
|
const url = new URL(window.location.href); |
||||||
|
url.searchParams.set("pay", "crypto"); |
||||||
|
url.searchParams.set("asset", asset); |
||||||
|
url.searchParams.set("payment_id", paymentId); |
||||||
|
window.location.href = url.toString(); |
||||||
|
} catch (err) { |
||||||
|
if (walletStatus) walletStatus.textContent = `Saved tx retry failed: ${err.message}`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (walletButton) { |
||||||
|
walletButton.addEventListener("click", async function() { |
||||||
|
const walletStatus = document.getElementById("walletStatusText"); |
||||||
|
const invoiceId = this.dataset.invoiceId; |
||||||
|
const paymentId = this.dataset.paymentId; |
||||||
|
const asset = this.dataset.asset; |
||||||
|
const chainId = this.dataset.chainId; |
||||||
|
const assetType = this.dataset.assetType; |
||||||
|
const to = this.dataset.to; |
||||||
|
const amount = this.dataset.amount; |
||||||
|
const decimals = Number(this.dataset.decimals || "18"); |
||||||
|
const tokenContract = this.dataset.tokenContract || ""; |
||||||
|
let chainAddParams = null; |
||||||
|
try { |
||||||
|
chainAddParams = this.dataset.chainAdd ? JSON.parse(this.dataset.chainAdd) : null; |
||||||
|
} catch (err) { |
||||||
|
chainAddParams = null; |
||||||
|
} |
||||||
|
|
||||||
|
const setStatus = (msg) => { |
||||||
|
if (walletStatus) walletStatus.textContent = msg; |
||||||
|
}; |
||||||
|
|
||||||
|
if (!window.ethereum || !window.ethereum.request) { |
||||||
|
setStatus("No browser wallet detected. Use MetaMask/Rabby or MetaMask Mobile."); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
this.disabled = true; |
||||||
|
setStatus("Opening wallet..."); |
||||||
|
|
||||||
|
await window.ethereum.request({ method: "eth_requestAccounts" }); |
||||||
|
|
||||||
|
if (chainId && chainId !== "None" && chainId !== "") { |
||||||
|
try { |
||||||
|
await switchChain(Number(chainId), chainAddParams); |
||||||
|
} catch (err) { |
||||||
|
setStatus(`Chain switch failed: ${err.message || err}`); |
||||||
|
this.disabled = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let txParams; |
||||||
|
if (assetType === "token" && tokenContract) { |
||||||
|
txParams = { |
||||||
|
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||||
|
to: tokenContract, |
||||||
|
data: erc20TransferData(to, amount, decimals), |
||||||
|
value: "0x0" |
||||||
|
}; |
||||||
|
} else { |
||||||
|
txParams = { |
||||||
|
from: (await window.ethereum.request({ method: "eth_accounts" }))[0], |
||||||
|
to: to, |
||||||
|
value: toHexBigIntFromDecimal(amount, decimals) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
setStatus("Waiting for wallet confirmation..."); |
||||||
|
const txHash = await window.ethereum.request({ |
||||||
|
method: "eth_sendTransaction", |
||||||
|
params: [txParams] |
||||||
|
}); |
||||||
|
|
||||||
|
if (!txHash || !String(txHash).startsWith("0x")) { |
||||||
|
throw new Error("wallet did not return a tx hash"); |
||||||
|
} |
||||||
|
|
||||||
|
const storageKey = pendingTxStorageKey(invoiceId, paymentId); |
||||||
|
localStorage.setItem(storageKey, String(txHash)); |
||||||
|
|
||||||
|
setStatus(`Wallet submitted tx: ${txHash}. Sending to billing server...`); |
||||||
|
|
||||||
|
await submitTxHash(invoiceId, paymentId, asset, txHash); |
||||||
|
|
||||||
|
localStorage.removeItem(storageKey); |
||||||
|
|
||||||
|
setStatus("Transaction submitted. Reloading into processing view..."); |
||||||
|
const url = new URL(window.location.href); |
||||||
|
url.searchParams.set("pay", "crypto"); |
||||||
|
url.searchParams.set("asset", asset); |
||||||
|
url.searchParams.set("payment_id", paymentId); |
||||||
|
window.location.href = url.toString(); |
||||||
|
} catch (err) { |
||||||
|
setStatus(`Wallet submit failed: ${err.message || err}`); |
||||||
|
this.disabled = false; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
tryRecoverPendingTxFromStorage(); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
const processingAutoRefreshEnabled = {{ 'true' if pending_crypto_payment and pending_crypto_payment.txid and (invoice.status or '')|lower != 'paid' else 'false' }}; |
||||||
|
if (processingAutoRefreshEnabled) { |
||||||
|
setTimeout(function() { |
||||||
|
window.location.reload(); |
||||||
|
}, 10000); |
||||||
|
} |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,102 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } |
||||||
|
.portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-note { margin-top: 1rem; opacity: 0.88; font-size: 0.95rem; } |
||||||
|
.portal-links { margin-top: 1rem; } |
||||||
|
.portal-links a { margin-right: 1rem; } |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="https://outsidethebox.top/">Home</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,82 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Set Portal Password - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<style> |
||||||
|
.portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } |
||||||
|
.portal-card { |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 16px; |
||||||
|
padding: 1.4rem; |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.18); |
||||||
|
} |
||||||
|
.portal-form { display: grid; gap: 0.9rem; } |
||||||
|
.portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } |
||||||
|
.portal-form input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.8rem 0.9rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
color: inherit; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
.portal-btn { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.8rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
text-decoration: none; |
||||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||||
|
background: rgba(255,255,255,0.06); |
||||||
|
color: inherit; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.portal-msg { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding: 0.85rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
} |
||||||
|
</style> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div style="background:#111827;padding:10px 20px;"> |
||||||
|
<a href="https://outsidethebox.top" style="color:#60a5fa;text-decoration:none;font-weight:bold;"> |
||||||
|
← OutsideTheBox Home |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1>Create Your Portal Password</h1> |
||||||
|
<p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/set-password"> |
||||||
|
<div> |
||||||
|
<label for="password">New Password</label> |
||||||
|
<input id="password" name="password" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label for="password2">Confirm Password</label> |
||||||
|
<input id="password2" name="password2" type="password" required> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<button class="portal-btn" type="submit">Set Password</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,125 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-page-header"> |
||||||
|
<div> |
||||||
|
<h1 class="portal-page-title">Client Dashboard</h1> |
||||||
|
<p class="portal-client-name">{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
<p class="portal-page-subtitle">Invoices, balances, and account activity in one place.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-toolbar"> |
||||||
|
<a class="portal-btn primary" href="/portal/invoices/download-all">Download All Invoices</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
<a class="portal-btn" href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div class="summary-value">{{ invoice_count }}</div> |
||||||
|
<div class="summary-sub">Invoices currently visible in your portal</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div class="summary-value">{{ total_outstanding }}</div> |
||||||
|
<div class="summary-sub">Current unpaid balance</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div class="summary-value">{{ total_paid }}</div> |
||||||
|
<div class="summary-sub">Payments already applied</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2 class="section-title">Invoices</h2> |
||||||
|
|
||||||
|
<div class="table-card"> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% if row.payment_method_label %} |
||||||
|
<div class="payment-method |
||||||
|
{% if row.payment_method_label == "Square" %} payment-square{% elif row.payment_method_label == "e-Transfer" %} payment-etransfer{% elif row.payment_method_label == "ETHO" %} payment-etho{% elif row.payment_method_label == "ETI" or row.payment_method_label == "EGAZ" %} payment-etica{% elif row.payment_method_label == "ALT" %} payment-alt{% elif row.payment_method_label == "CAD" %} payment-cad{% endif %}"> |
||||||
|
{{ row.payment_method_label }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { window.location.reload(); }, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Forgot Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } .portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Reset Portal Password</h1> <p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> {% if error %} <div class="portal-msg">{{ error }}</div> {% endif %} {% if message %} <div class="portal-msg">{{ message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/forgot-password"> <div> <label for="email">Email Address</label> <input id="email" name="email" type="email" value="{{ form_email or '' }}" required> </div> <div class="portal-actions"> <button class="portal-btn" type="submit">Send Reset Code</button> <a class="portal-btn" href="/portal">Back to Portal Login</a> <a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support" href="mailto:support@outsidethebox.top">Customer Support</a> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,64 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1 class="portal-page-title">OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn primary" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Set Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Create Your Portal Password</h1> <p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> {% if portal_message %} <div class="portal-msg">{{ portal_message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/set-password"> <div> <label for="password">New Password</label> <input id="password" name="password" type="password" required> </div> <div> <label for="password2">Confirm Password</label> <input id="password2" name="password2" type="password" required> </div> <div> <button class="portal-btn" type="submit">Set Password</button> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,125 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-page-header"> |
||||||
|
<div> |
||||||
|
<h1 class="portal-page-title">Client Dashboard</h1> |
||||||
|
<p class="portal-client-name">{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
<p class="portal-page-subtitle">Invoices, balances, and account activity in one place.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-toolbar"> |
||||||
|
<a class="portal-btn primary" href="/portal/invoices/download-all">Download All Invoices</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
<a class="portal-btn" href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div class="summary-value">{{ invoice_count }}</div> |
||||||
|
<div class="summary-sub">Invoices currently visible in your portal</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div class="summary-value">{{ total_outstanding }}</div> |
||||||
|
<div class="summary-sub">Current unpaid balance</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div class="summary-value">{{ total_paid }}</div> |
||||||
|
<div class="summary-sub">Payments already applied</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2 class="section-title">Invoices</h2> |
||||||
|
|
||||||
|
<div class="table-card"> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% if row.payment_method_label %} |
||||||
|
<div class="payment-method |
||||||
|
{% if row.payment_method_label == "Square" %} payment-square{% elif row.payment_method_label == "e-Transfer" %} payment-etransfer{% elif row.payment_method_label == "ETHO" %} payment-etho{% elif row.payment_method_label == "ETI" or row.payment_method_label == "EGAZ" %} payment-etica{% elif row.payment_method_label == "ALT" %} payment-alt{% elif row.payment_method_label == "CAD" %} payment-cad{% endif %}"> |
||||||
|
{{ row.payment_method_label }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { window.location.reload(); }, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Forgot Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } .portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Reset Portal Password</h1> <p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> {% if error %} <div class="portal-msg">{{ error }}</div> {% endif %} {% if message %} <div class="portal-msg">{{ message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/forgot-password"> <div> <label for="email">Email Address</label> <input id="email" name="email" type="email" value="{{ form_email or '' }}" required> </div> <div class="portal-actions"> <button class="portal-btn" type="submit">Send Reset Code</button> <a class="portal-btn" href="/portal">Back to Portal Login</a> <a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support" href="mailto:support@outsidethebox.top">Customer Support</a> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,64 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1 class="portal-page-title">OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn primary" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Set Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Create Your Portal Password</h1> <p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> {% if portal_message %} <div class="portal-msg">{{ portal_message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/set-password"> <div> <label for="password">New Password</label> <input id="password" name="password" type="password" required> </div> <div> <label for="password2">Confirm Password</label> <input id="password2" name="password2" type="password" required> </div> <div> <button class="portal-btn" type="submit">Set Password</button> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Client Dashboard - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } .portal-top { display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom: 1rem; } .portal-actions a { margin-left: 0.75rem; text-decoration: underline; } .summary-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin: 1rem 0 1.25rem 0; } .summary-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); } .summary-card h3 { margin-top:0; margin-bottom:0.4rem; } table.portal-table { width: 100%; border-collapse: collapse; } table.portal-table th, table.portal-table td { padding: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; } table.portal-table th { background: #e9eef7; color: #10203f; } .invoice-link { color: inherit; text-decoration: underline; font-weight: 600; } .status-badge { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.86rem; font-weight: 700; } .status-paid { background: rgba(34, 197, 94, 0.18); color: #4ade80; } .status-pending { background: rgba(245, 158, 11, 0.20); color: #fbbf24; } .status-overdue { background: rgba(239, 68, 68, 0.18); color: #f87171; } .status-other { background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-top"> <div> <h1>Client Dashboard</h1> <p>{{ client.company_name or client.contact_name or client.email }}</p> </div> <div class="portal-actions"> <a href="/portal/invoices/download-all">Download All Invoices (ZIP)</a> <a href="mailto:support@outsidethebox.top?subject=Portal%20Support" href="mailto:support@outsidethebox.top">Customer Support</a> <a href="/portal/logout">Logout</a> </div> </div> <div class="summary-grid"> <div class="summary-card"> <h3>Total Invoices</h3> <div>{{ invoice_count }}</div> </div> <div class="summary-card"> <h3>Total Outstanding</h3> <div>{{ total_outstanding }}</div> </div> <div class="summary-card"> <h3>Total Paid</h3> <div>{{ total_paid }}</div> </div> </div> <h2>Invoices</h2> <table class="portal-table"> <thead> <tr> <th>Invoice</th> <th>Status</th> <th>Created</th> <th>Total</th> <th>Paid</th> <th>Outstanding</th> </tr> </thead> <tbody> {% for row in invoices %} <tr> <td> <a class="invoice-link" href="/portal/invoice/{{ row.id }}"> {{ row.invoice_number or ("INV-" ~ row.id) }} </a> </td> <td> {% set s = (row.status or "")|lower %} {% if s == "paid" %} <span class="status-badge status-paid">{{ row.status }}</span> {% elif s == "pending" %} <span class="status-badge status-pending">{{ row.status }}</span> {% elif s == "overdue" %} <span class="status-badge status-overdue">{{ row.status }}</span> {% else %} <span class="status-badge status-other">{{ row.status }}</span> {% endif %} </td> <td>{{ row.created_at }}</td> <td>{{ row.total_amount }}</td> <td>{{ row.amount_paid }}</td> <td>{{ row.outstanding }}</td> </tr> {% else %} <tr> <td colspan="6">No invoices available.</td> </tr> {% endfor %} </tbody> </table> |
||||||
|
</div> <script> |
||||||
|
(function() { setTimeout(function() { window.location.reload(); }, 20000); |
||||||
|
})(); |
||||||
|
</script> {% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Client Portal - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } .portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-note { margin-top: 1rem; opacity: 0.88; font-size: 0.95rem; } .portal-links { margin-top: 1rem; } .portal-links a { margin-right: 1rem; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>OutsideTheBox Client Portal</h1> <p class="portal-sub">Secure access for invoices, balances, and account information.</p> {% if portal_message %} <div class="portal-msg">{{ portal_message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/login"> <div> <label for="email">Email Address</label> <input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> </div> <div> <label for="credential">Access Code or Password</label> <input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> </div> <div class="portal-actions"> <button class="portal-btn" type="submit">Sign In</button> <a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support" href="mailto:support@outsidethebox.top">Customer Support</a> </div> </form> <div style="margin-top:15px;"> <a href="/portal/forgot-password">Forgot your password?</a> </div> <p class="portal-note"> First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. This access code is single-use and is cleared after password setup. Future logins use your email address and password. </p> </div> |
||||||
|
</div> {% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,134 @@ |
|||||||
|
/* ===== GLOBAL ===== */ |
||||||
|
body { |
||||||
|
background: linear-gradient(135deg, #0a1628, #0c1f3f); |
||||||
|
color: #e6edf3; |
||||||
|
font-family: system-ui, -apple-system, sans-serif; |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== CONTAINER ===== */ |
||||||
|
.container { |
||||||
|
max-width: 1100px; |
||||||
|
margin: 40px auto; |
||||||
|
padding: 0 20px; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== HEADERS ===== */ |
||||||
|
h1 { |
||||||
|
font-size: 28px; |
||||||
|
margin-bottom: 5px; |
||||||
|
} |
||||||
|
|
||||||
|
.subtext { |
||||||
|
color: #9fb3c8; |
||||||
|
margin-bottom: 25px; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== ACTION BAR ===== */ |
||||||
|
.action-bar { |
||||||
|
display: flex; |
||||||
|
gap: 10px; |
||||||
|
margin-bottom: 25px; |
||||||
|
flex-wrap: wrap; |
||||||
|
} |
||||||
|
|
||||||
|
.btn { |
||||||
|
background: #132a4a; |
||||||
|
border: 1px solid #2c4d75; |
||||||
|
color: #e6edf3; |
||||||
|
padding: 10px 16px; |
||||||
|
border-radius: 8px; |
||||||
|
text-decoration: none; |
||||||
|
font-size: 14px; |
||||||
|
transition: all 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.btn:hover { |
||||||
|
background: #1a3a66; |
||||||
|
border-color: #3d6ea8; |
||||||
|
} |
||||||
|
|
||||||
|
.btn-primary { |
||||||
|
background: #1e6fff; |
||||||
|
border-color: #1e6fff; |
||||||
|
} |
||||||
|
|
||||||
|
.btn-primary:hover { |
||||||
|
background: #3b82ff; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== STATS ===== */ |
||||||
|
.stats { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
||||||
|
gap: 15px; |
||||||
|
margin-bottom: 30px; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card { |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
border: 1px solid rgba(255,255,255,0.08); |
||||||
|
border-radius: 12px; |
||||||
|
padding: 18px; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card h3 { |
||||||
|
font-size: 14px; |
||||||
|
color: #9fb3c8; |
||||||
|
margin-bottom: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card p { |
||||||
|
font-size: 20px; |
||||||
|
font-weight: bold; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== TABLE ===== */ |
||||||
|
.table-container { |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
border: 1px solid rgba(255,255,255,0.08); |
||||||
|
border-radius: 12px; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
|
||||||
|
th { |
||||||
|
text-align: left; |
||||||
|
padding: 12px; |
||||||
|
font-size: 13px; |
||||||
|
color: #9fb3c8; |
||||||
|
background: rgba(255,255,255,0.05); |
||||||
|
} |
||||||
|
|
||||||
|
td { |
||||||
|
padding: 12px; |
||||||
|
border-top: 1px solid rgba(255,255,255,0.05); |
||||||
|
} |
||||||
|
|
||||||
|
tr:hover { |
||||||
|
background: rgba(255,255,255,0.03); |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== STATUS ===== */ |
||||||
|
.status-paid { |
||||||
|
color: #22c55e; |
||||||
|
font-weight: bold; |
||||||
|
} |
||||||
|
|
||||||
|
.status-unpaid { |
||||||
|
color: #f59e0b; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== LINKS ===== */ |
||||||
|
a { |
||||||
|
color: #60a5fa; |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
a:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
@ -0,0 +1,125 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-page-header"> |
||||||
|
<div> |
||||||
|
<h1 class="portal-page-title">Client Dashboard</h1> |
||||||
|
<p class="portal-client-name">{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
<p class="portal-page-subtitle">Invoices, balances, and account activity in one place.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-toolbar"> |
||||||
|
<a class="portal-btn primary" href="/portal/invoices/download-all">Download All Invoices</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
<a class="portal-btn" href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div class="summary-value">{{ invoice_count }}</div> |
||||||
|
<div class="summary-sub">Invoices currently visible in your portal</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div class="summary-value">{{ total_outstanding }}</div> |
||||||
|
<div class="summary-sub">Current unpaid balance</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div class="summary-value">{{ total_paid }}</div> |
||||||
|
<div class="summary-sub">Payments already applied</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2 class="section-title">Invoices</h2> |
||||||
|
|
||||||
|
<div class="table-card"> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% if row.payment_method_label %} |
||||||
|
<div class="payment-method |
||||||
|
{% if row.payment_method_label == "Square" %} payment-square{% elif row.payment_method_label == "e-Transfer" %} payment-etransfer{% elif row.payment_method_label == "ETHO" %} payment-etho{% elif row.payment_method_label == "ETI" or row.payment_method_label == "EGAZ" %} payment-etica{% elif row.payment_method_label == "ALT" %} payment-alt{% elif row.payment_method_label == "CAD" %} payment-cad{% endif %}"> |
||||||
|
{{ row.payment_method_label }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { window.location.reload(); }, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Forgot Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } .portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Reset Portal Password</h1> <p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> {% if error %} <div class="portal-msg">{{ error }}</div> {% endif %} {% if message %} <div class="portal-msg">{{ message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/forgot-password"> <div> <label for="email">Email Address</label> <input id="email" name="email" type="email" value="{{ form_email or '' }}" required> </div> <div class="portal-actions"> <button class="portal-btn" type="submit">Send Reset Code</button> <a class="portal-btn" href="/portal">Back to Portal Login</a> <a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support" href="mailto:support@outsidethebox.top">Customer Support</a> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,64 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1 class="portal-page-title">OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn primary" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Set Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Create Your Portal Password</h1> <p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> {% if portal_message %} <div class="portal-msg">{{ portal_message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/set-password"> <div> <label for="password">New Password</label> <input id="password" name="password" type="password" required> </div> <div> <label for="password2">Confirm Password</label> <input id="password2" name="password2" type="password" required> </div> <div> <button class="portal-btn" type="submit">Set Password</button> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
<header class="header"> |
||||||
|
<div class="container"> |
||||||
|
<div class="nav"> |
||||||
|
<a class="brand" href="https://outsidethebox.top"> |
||||||
|
<img src="/static/favicon.png" alt="outsidethebox.top logo" /> |
||||||
|
<div class="title"> |
||||||
|
<strong>outsidethebox.top</strong> |
||||||
|
<span>Managed hosting • no client server logins</span> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
|
||||||
|
<nav class="navlinks"> |
||||||
|
<a href="https://outsidethebox.top">Home</a> |
||||||
|
<a href="https://outsidethebox.top/pricing.html">Pricing</a> |
||||||
|
<a href="https://outsidethebox.top/terms.html">ToS</a> |
||||||
|
<a href="https://outsidethebox.top/contact.html">Contact</a> |
||||||
|
<div class="dropdown"> |
||||||
|
<a href="#" class="dropdown-toggle">Services</a> |
||||||
|
<div class="dropdown-menu"> |
||||||
|
<a href="https://follow-me.outsidethebox.top">Follow-me Tracker</a> |
||||||
|
<a href="https://monitor.outsidethebox.top">Oracle</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<a href="/portal">Portal</a> |
||||||
|
</nav> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</header> |
||||||
@ -0,0 +1,794 @@ |
|||||||
|
:root{ |
||||||
|
--bg:#0b0f14; |
||||||
|
--card:#121825; |
||||||
|
--card-soft:rgba(18,24,37,.78); |
||||||
|
--text:#e8eefc; |
||||||
|
--muted:#aab6d6; |
||||||
|
--line:#24304a; |
||||||
|
--accent:#7aa2ff; |
||||||
|
--accent2:#62e6b7; |
||||||
|
--success:#4ade80; |
||||||
|
--warn:#fbbf24; |
||||||
|
--danger:#f87171; |
||||||
|
--radius:16px; |
||||||
|
--shadow:0 16px 40px rgba(0,0,0,.35); |
||||||
|
} |
||||||
|
|
||||||
|
*{box-sizing:border-box} |
||||||
|
html,body{height:100%} |
||||||
|
|
||||||
|
body{ |
||||||
|
margin:0; |
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji","Segoe UI Emoji"; |
||||||
|
background: |
||||||
|
radial-gradient(1200px 600px at 20% 0%, rgba(122,162,255,.22), transparent 60%), |
||||||
|
radial-gradient(900px 500px at 90% 20%, rgba(98,230,183,.15), transparent 60%), |
||||||
|
linear-gradient(180deg, #081225 0%, #09172d 100%); |
||||||
|
color:var(--text); |
||||||
|
line-height:1.45; |
||||||
|
} |
||||||
|
|
||||||
|
a{color:inherit} |
||||||
|
|
||||||
|
/* ===== Shared container ===== */ |
||||||
|
.container{ |
||||||
|
max-width:1100px; |
||||||
|
margin:0 auto; |
||||||
|
padding:20px 18px; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Header / branded nav ===== */ |
||||||
|
.header{width:100%} |
||||||
|
|
||||||
|
.nav{ |
||||||
|
display:flex; |
||||||
|
align-items:center; |
||||||
|
justify-content:space-between; |
||||||
|
gap:18px; |
||||||
|
padding:12px 0 22px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.brand{ |
||||||
|
display:flex; |
||||||
|
align-items:center; |
||||||
|
gap:14px; |
||||||
|
text-decoration:none; |
||||||
|
} |
||||||
|
|
||||||
|
.brand img{ |
||||||
|
height:60px; |
||||||
|
width:auto; |
||||||
|
display:block; |
||||||
|
object-fit:contain; |
||||||
|
background: rgba(255,255,255,0.92); |
||||||
|
padding: 6px 12px; |
||||||
|
border-radius: 999px; |
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.35); |
||||||
|
} |
||||||
|
|
||||||
|
.title{ |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
line-height:1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.title strong{letter-spacing:.2px} |
||||||
|
.title span{ |
||||||
|
color:var(--muted); |
||||||
|
font-size:13px; |
||||||
|
margin-top:2px; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks{ |
||||||
|
display:flex; |
||||||
|
gap:12px; |
||||||
|
flex-wrap:wrap; |
||||||
|
justify-content:flex-end; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a{ |
||||||
|
text-decoration:none; |
||||||
|
padding:8px 10px; |
||||||
|
border-radius:12px; |
||||||
|
color:var(--muted); |
||||||
|
border:1px solid transparent; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a:hover{ |
||||||
|
color:var(--text); |
||||||
|
border-color:rgba(255,255,255,.08); |
||||||
|
background:rgba(255,255,255,.03); |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks a.active{ |
||||||
|
color:var(--text); |
||||||
|
border-color:rgba(255,255,255,.10); |
||||||
|
background:rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Generic portal shell ===== */ |
||||||
|
.portal-shell{ |
||||||
|
max-width:1100px; |
||||||
|
margin:16px auto 28px auto; |
||||||
|
padding:0 18px 20px 18px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-card, |
||||||
|
.detail-card, |
||||||
|
.summary-card, |
||||||
|
.pay-card{ |
||||||
|
background: var(--card-soft); |
||||||
|
border: 1px solid rgba(255,255,255,.07); |
||||||
|
border-radius: var(--radius); |
||||||
|
box-shadow: var(--shadow); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-card{ |
||||||
|
max-width:760px; |
||||||
|
margin:24px auto 12px auto; |
||||||
|
padding:22px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-header{ |
||||||
|
display:flex; |
||||||
|
align-items:flex-start; |
||||||
|
justify-content:space-between; |
||||||
|
gap:16px; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin:6px 0 18px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-title{ |
||||||
|
margin:0; |
||||||
|
font-size:24px; |
||||||
|
line-height:1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-page-subtitle{ |
||||||
|
margin:8px 0 0 0; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-client-name{ |
||||||
|
margin:8px 0 0 0; |
||||||
|
color:var(--text); |
||||||
|
font-size:15px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-toolbar{ |
||||||
|
display:flex; |
||||||
|
gap:10px; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn, |
||||||
|
.btn, |
||||||
|
.pay-btn, |
||||||
|
.quote-pick-btn{ |
||||||
|
display:inline-flex; |
||||||
|
align-items:center; |
||||||
|
justify-content:center; |
||||||
|
gap:8px; |
||||||
|
min-height:42px; |
||||||
|
padding:10px 14px; |
||||||
|
border-radius:12px; |
||||||
|
text-decoration:none; |
||||||
|
border:1px solid rgba(255,255,255,.10); |
||||||
|
background: rgba(255,255,255,.05); |
||||||
|
color:var(--text); |
||||||
|
font-weight:600; |
||||||
|
cursor:pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn:hover, |
||||||
|
.btn:hover, |
||||||
|
.pay-btn:hover, |
||||||
|
.quote-pick-btn:hover{ |
||||||
|
border-color:rgba(122,162,255,.45); |
||||||
|
box-shadow:0 0 0 4px rgba(122,162,255,.12); |
||||||
|
text-decoration:none; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn.primary, |
||||||
|
.btn.primary{ |
||||||
|
background: linear-gradient(135deg, rgba(122,162,255,.95), rgba(98,230,183,.85)); |
||||||
|
border-color: transparent; |
||||||
|
color:#071017; |
||||||
|
font-weight:700; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-btn.primary:hover, |
||||||
|
.btn.primary:hover{ |
||||||
|
box-shadow:0 0 0 4px rgba(98,230,183,.18); |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Login / forms ===== */ |
||||||
|
.portal-sub{ |
||||||
|
color:var(--muted); |
||||||
|
margin:0 0 16px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form{ |
||||||
|
display:grid; |
||||||
|
gap:14px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form label{ |
||||||
|
display:block; |
||||||
|
font-weight:600; |
||||||
|
margin-bottom:6px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form input, |
||||||
|
.portal-form select, |
||||||
|
.pay-selector{ |
||||||
|
width:100%; |
||||||
|
padding:12px 14px; |
||||||
|
border-radius:12px; |
||||||
|
border:1px solid rgba(255,255,255,.14); |
||||||
|
background: rgba(255,255,255,.06); |
||||||
|
color:var(--text); |
||||||
|
box-sizing:border-box; |
||||||
|
outline:none; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-form input:focus, |
||||||
|
.portal-form select:focus, |
||||||
|
.pay-selector:focus{ |
||||||
|
border-color:rgba(122,162,255,.65); |
||||||
|
box-shadow:0 0 0 4px rgba(122,162,255,.12); |
||||||
|
} |
||||||
|
|
||||||
|
.portal-actions{ |
||||||
|
display:flex; |
||||||
|
gap:10px; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:4px; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-note{ |
||||||
|
margin-top:16px; |
||||||
|
color:var(--muted); |
||||||
|
font-size:14px; |
||||||
|
line-height:1.5; |
||||||
|
} |
||||||
|
|
||||||
|
.portal-msg, |
||||||
|
.error-box, |
||||||
|
.success-box{ |
||||||
|
margin-bottom:16px; |
||||||
|
padding:12px 14px; |
||||||
|
border-radius:12px; |
||||||
|
border:1px solid rgba(255,255,255,.16); |
||||||
|
background: rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
.error-box{ |
||||||
|
border-color: rgba(239, 68, 68, 0.55); |
||||||
|
background: rgba(127, 29, 29, 0.22); |
||||||
|
color: #fecaca; |
||||||
|
} |
||||||
|
|
||||||
|
.success-box{ |
||||||
|
border-color: rgba(34, 197, 94, 0.55); |
||||||
|
background: rgba(22, 101, 52, 0.18); |
||||||
|
color: #dcfce7; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Dashboard ===== */ |
||||||
|
.portal-wrap{ |
||||||
|
max-width:1100px; |
||||||
|
margin:0 auto; |
||||||
|
padding:0; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-grid, |
||||||
|
.detail-grid{ |
||||||
|
display:grid; |
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)); |
||||||
|
gap:14px; |
||||||
|
margin: 0 0 18px 0; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card, |
||||||
|
.detail-card{ |
||||||
|
padding:18px; |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card h3, |
||||||
|
.detail-card h3{ |
||||||
|
margin:0 0 8px 0; |
||||||
|
font-size:14px; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card .summary-value, |
||||||
|
.detail-card .detail-value{ |
||||||
|
font-size:28px; |
||||||
|
font-weight:800; |
||||||
|
line-height:1.1; |
||||||
|
color:var(--text); |
||||||
|
} |
||||||
|
|
||||||
|
.summary-card .summary-sub{ |
||||||
|
margin-top:6px; |
||||||
|
font-size:12px; |
||||||
|
color:var(--muted); |
||||||
|
} |
||||||
|
|
||||||
|
.section-title{ |
||||||
|
margin:18px 0 10px 0; |
||||||
|
font-size:22px; |
||||||
|
} |
||||||
|
|
||||||
|
.table-card{ |
||||||
|
background: var(--card-soft); |
||||||
|
border: 1px solid rgba(255,255,255,.07); |
||||||
|
border-radius: var(--radius); |
||||||
|
box-shadow: var(--shadow); |
||||||
|
overflow:hidden; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Tables ===== */ |
||||||
|
table, |
||||||
|
.portal-table, |
||||||
|
.quote-table{ |
||||||
|
width:100%; |
||||||
|
border-collapse: collapse; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table th, |
||||||
|
table.portal-table td, |
||||||
|
.quote-table th, |
||||||
|
.quote-table td{ |
||||||
|
padding: 0.9rem 0.85rem; |
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.10); |
||||||
|
text-align: left; |
||||||
|
vertical-align: middle; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table th, |
||||||
|
.quote-table th{ |
||||||
|
background: rgba(255,255,255,.06); |
||||||
|
color: var(--text); |
||||||
|
font-size:13px; |
||||||
|
letter-spacing:.02em; |
||||||
|
} |
||||||
|
|
||||||
|
table.portal-table tr:hover td, |
||||||
|
.quote-table tr:hover td{ |
||||||
|
background: rgba(255,255,255,.02); |
||||||
|
} |
||||||
|
|
||||||
|
.invoice-link{ |
||||||
|
color: var(--text); |
||||||
|
text-decoration: none; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.invoice-link:hover{ |
||||||
|
color: var(--accent); |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Badges ===== */ |
||||||
|
.status-badge, |
||||||
|
.quote-badge{ |
||||||
|
display: inline-block; |
||||||
|
padding: 0.22rem 0.62rem; |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.82rem; |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.status-paid{ background: rgba(34, 197, 94, 0.18); color: var(--success); } |
||||||
|
.status-pending{ background: rgba(245, 158, 11, 0.20); color: var(--warn); } |
||||||
|
.status-overdue{ background: rgba(239, 68, 68, 0.18); color: var(--danger); } |
||||||
|
.status-other{ background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } |
||||||
|
|
||||||
|
.quote-live{ background: rgba(34, 197, 94, 0.18); color: var(--success); } |
||||||
|
.quote-stale{ background: rgba(239, 68, 68, 0.18); color: var(--danger); } |
||||||
|
|
||||||
|
/* ===== Payments / invoice detail ===== */ |
||||||
|
.pay-card{ |
||||||
|
padding:18px; |
||||||
|
margin-top: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pay-selector-row{ |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
align-items:center; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pay-panel{ |
||||||
|
margin-top: 1rem; |
||||||
|
padding: 1rem; |
||||||
|
border: 1px solid rgba(255,255,255,0.12); |
||||||
|
border-radius: 12px; |
||||||
|
background: rgba(255,255,255,0.02); |
||||||
|
} |
||||||
|
|
||||||
|
.pay-panel.hidden{ display:none; } |
||||||
|
|
||||||
|
.pay-btn-square { background:#16a34a; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-wallet { background:#2563eb; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-mobile { background:#7c3aed; border-color:transparent; color:#fff; } |
||||||
|
.pay-btn-copy { background:#374151; border-color:transparent; color:#fff; } |
||||||
|
|
||||||
|
.snapshot-wrap{ |
||||||
|
position: relative; |
||||||
|
margin-top: 1rem; |
||||||
|
border: 1px solid rgba(255,255,255,0.14); |
||||||
|
border-radius: 14px; |
||||||
|
padding: 1rem; |
||||||
|
background: rgba(255,255,255,0.02); |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-header{ |
||||||
|
display:flex; |
||||||
|
justify-content:space-between; |
||||||
|
gap:1rem; |
||||||
|
align-items:flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-meta{ |
||||||
|
flex: 1 1 auto; |
||||||
|
min-width: 0; |
||||||
|
line-height: 1.65; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-box{ |
||||||
|
width: 220px; |
||||||
|
min-height: 132px; |
||||||
|
border: 1px solid rgba(255,255,255,0.16); |
||||||
|
border-radius: 14px; |
||||||
|
background: rgba(0,0,0,0.18); |
||||||
|
display:flex; |
||||||
|
flex-direction:column; |
||||||
|
justify-content:center; |
||||||
|
align-items:center; |
||||||
|
text-align:center; |
||||||
|
padding: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-value{ |
||||||
|
font-size: 2rem; |
||||||
|
font-weight: 800; |
||||||
|
line-height: 1.1; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-label{ |
||||||
|
margin-top: 0.55rem; |
||||||
|
font-size: 0.95rem; |
||||||
|
opacity: 0.95; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-expired{ color: var(--danger); } |
||||||
|
|
||||||
|
.lock-box{ |
||||||
|
margin-top: 1rem; |
||||||
|
border: 1px solid rgba(34, 197, 94, 0.28); |
||||||
|
background: rgba(22, 101, 52, 0.16); |
||||||
|
border-radius: 12px; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.lock-box.expired{ |
||||||
|
border-color: rgba(239, 68, 68, 0.55); |
||||||
|
background: rgba(127, 29, 29, 0.22); |
||||||
|
} |
||||||
|
|
||||||
|
.lock-grid{ |
||||||
|
display:grid; |
||||||
|
grid-template-columns: 1fr 220px; |
||||||
|
gap:1rem; |
||||||
|
align-items:start; |
||||||
|
} |
||||||
|
|
||||||
|
.lock-code{ |
||||||
|
display:block; |
||||||
|
margin-top:0.35rem; |
||||||
|
padding:0.65rem 0.8rem; |
||||||
|
background: rgba(0,0,0,0.22); |
||||||
|
border-radius: 8px; |
||||||
|
overflow-wrap:anywhere; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-actions{ |
||||||
|
display:flex; |
||||||
|
gap:0.75rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
margin-top:0.9rem; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help{ |
||||||
|
margin-top: 0.85rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
border-radius: 10px; |
||||||
|
background: rgba(255,255,255,0.04); |
||||||
|
border: 1px solid rgba(255,255,255,0.10); |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help h4{ |
||||||
|
margin: 0 0 0.55rem 0; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.wallet-help p{ margin: 0.35rem 0; } |
||||||
|
.wallet-note{ opacity:0.9; margin-top:0.65rem; } |
||||||
|
.mono{ font-family: monospace; } |
||||||
|
|
||||||
|
.copy-row{ |
||||||
|
display:flex; |
||||||
|
gap:0.5rem; |
||||||
|
flex-wrap:wrap; |
||||||
|
align-items:center; |
||||||
|
margin-top:0.65rem; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-target{ |
||||||
|
flex: 1 1 420px; |
||||||
|
min-width: 220px; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-status{ |
||||||
|
display:inline-block; |
||||||
|
margin-left: 0.5rem; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Footer ===== */ |
||||||
|
footer{ |
||||||
|
margin:28px auto 20px auto; |
||||||
|
max-width:1100px; |
||||||
|
padding:0 18px; |
||||||
|
color:var(--muted); |
||||||
|
font-size:13px; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Responsive ===== */ |
||||||
|
@media (max-width: 900px){ |
||||||
|
.summary-grid, |
||||||
|
.detail-grid{ |
||||||
|
grid-template-columns:1fr; |
||||||
|
} |
||||||
|
|
||||||
|
.nav{ |
||||||
|
align-items:flex-start; |
||||||
|
flex-direction:column; |
||||||
|
} |
||||||
|
|
||||||
|
.navlinks{ |
||||||
|
justify-content:flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.brand img{ |
||||||
|
height:54px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 820px){ |
||||||
|
.snapshot-header, |
||||||
|
.lock-grid{ |
||||||
|
grid-template-columns: 1fr; |
||||||
|
display:block; |
||||||
|
} |
||||||
|
|
||||||
|
.snapshot-timer-box{ |
||||||
|
width: 100%; |
||||||
|
margin-top: 1rem; |
||||||
|
min-height: 110px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/* ===== Fixed CAD / Oracle status bar ===== */ |
||||||
|
body{ |
||||||
|
padding-bottom: 56px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar{ |
||||||
|
position: fixed; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
z-index: 9999; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
min-height: 42px; |
||||||
|
padding: 8px 14px; |
||||||
|
background: rgba(8, 16, 32, 0.94); |
||||||
|
border-top: 1px solid rgba(255,255,255,.10); |
||||||
|
backdrop-filter: blur(8px); |
||||||
|
box-shadow: 0 -8px 24px rgba(0,0,0,.28); |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar-inner{ |
||||||
|
width: 100%; |
||||||
|
max-width: 1100px; |
||||||
|
display: flex; |
||||||
|
gap: 10px; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
flex-wrap: wrap; |
||||||
|
text-align: center; |
||||||
|
color: var(--muted); |
||||||
|
font-size: 12px; |
||||||
|
line-height: 1.35; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar strong{ |
||||||
|
color: var(--text); |
||||||
|
font-weight: 700; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar a{ |
||||||
|
color: var(--accent2); |
||||||
|
text-decoration: none; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar a:hover{ |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-dot{ |
||||||
|
width: 6px; |
||||||
|
height: 6px; |
||||||
|
border-radius: 999px; |
||||||
|
display: inline-block; |
||||||
|
background: rgba(255,255,255,.25); |
||||||
|
flex: 0 0 auto; |
||||||
|
} |
||||||
|
|
||||||
|
/* ===== Payment method chips ===== */ |
||||||
|
.payment-method{ |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
gap: 7px; |
||||||
|
margin-top: 7px; |
||||||
|
padding: 4px 9px; |
||||||
|
border-radius: 999px; |
||||||
|
background: rgba(255,255,255,.05); |
||||||
|
border: 1px solid rgba(255,255,255,.08); |
||||||
|
color: var(--muted); |
||||||
|
font-size: 11px; |
||||||
|
font-weight: 700; |
||||||
|
letter-spacing: .01em; |
||||||
|
} |
||||||
|
|
||||||
|
.payment-method::before{ |
||||||
|
content: ""; |
||||||
|
width: 8px; |
||||||
|
height: 8px; |
||||||
|
border-radius: 999px; |
||||||
|
display: inline-block; |
||||||
|
background: #7aa2ff; |
||||||
|
box-shadow: 0 0 0 3px rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
.payment-square::before{ background: #7dd3fc; } |
||||||
|
.payment-etransfer::before{ background: #86efac; } |
||||||
|
.payment-etho::before{ background: #b084ff; } |
||||||
|
.payment-etica::before{ background: #4fd1c5; } |
||||||
|
.payment-alt::before{ background: #fbbf24; } |
||||||
|
.payment-cad::before{ background: #cbd5e1; } |
||||||
|
|
||||||
|
/* ===== Slightly stronger row hover ===== */ |
||||||
|
table.portal-table tbody tr:hover td, |
||||||
|
.quote-table tbody tr:hover td{ |
||||||
|
background: rgba(255,255,255,.035); |
||||||
|
transition: background .14s ease; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 700px){ |
||||||
|
body{ |
||||||
|
padding-bottom: 72px; |
||||||
|
} |
||||||
|
|
||||||
|
.otb-statusbar-inner{ |
||||||
|
font-size: 11px; |
||||||
|
line-height: 1.25; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 900px){ |
||||||
|
.dropdown{ |
||||||
|
width:100%; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-toggle{ |
||||||
|
width:100%; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu{ |
||||||
|
position:static; |
||||||
|
right:auto; |
||||||
|
top:auto; |
||||||
|
min-width:100%; |
||||||
|
margin-top:6px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/* ===== Shared services dropdown ===== */ |
||||||
|
.navlinks{ |
||||||
|
display:flex; |
||||||
|
gap:12px; |
||||||
|
flex-wrap:wrap; |
||||||
|
justify-content:flex-end; |
||||||
|
align-items:center; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown{ |
||||||
|
position:relative; |
||||||
|
display:inline-block; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-toggle{ |
||||||
|
display:inline-block; |
||||||
|
cursor:pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu{ |
||||||
|
position:absolute; |
||||||
|
top:calc(100% + 8px); |
||||||
|
right:0; |
||||||
|
min-width:220px; |
||||||
|
display:none; |
||||||
|
padding:10px; |
||||||
|
border-radius:14px; |
||||||
|
background:rgba(18,24,37,.98); |
||||||
|
border:1px solid rgba(255,255,255,.08); |
||||||
|
box-shadow:0 16px 40px rgba(0,0,0,.35); |
||||||
|
z-index:9999; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown:hover .dropdown-menu, |
||||||
|
.dropdown:focus-within .dropdown-menu{ |
||||||
|
display:block; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu a{ |
||||||
|
display:block; |
||||||
|
padding:9px 10px; |
||||||
|
border-radius:10px; |
||||||
|
color:var(--muted); |
||||||
|
text-decoration:none; |
||||||
|
white-space:nowrap; |
||||||
|
margin:0; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu a + a{ |
||||||
|
margin-top:4px; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu a:hover{ |
||||||
|
color:var(--text); |
||||||
|
background:rgba(255,255,255,.04); |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 900px){ |
||||||
|
.dropdown{ |
||||||
|
width:100%; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-toggle{ |
||||||
|
width:100%; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu{ |
||||||
|
position:static; |
||||||
|
right:auto; |
||||||
|
top:auto; |
||||||
|
min-width:100%; |
||||||
|
margin-top:6px; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,126 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-page-header"> |
||||||
|
<div> |
||||||
|
<h1 class="portal-page-title">Client Dashboard</h1> |
||||||
|
<p class="portal-client-name">{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
<p class="portal-page-subtitle">Invoices, balances, and account activity in one place.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-toolbar"> |
||||||
|
<a class="portal-btn primary" href="/portal/invoices/download-all">Download All Invoices</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
<a class="portal-btn" href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div class="summary-value">{{ invoice_count }}</div> |
||||||
|
<div class="summary-sub">Invoices currently visible in your portal</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div class="summary-value">{{ total_outstanding }}</div> |
||||||
|
<div class="summary-sub">Current unpaid balance</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div class="summary-value">{{ total_paid }}</div> |
||||||
|
<div class="summary-sub">Payments already applied</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2 class="section-title">Invoices</h2> |
||||||
|
|
||||||
|
<div class="table-card"> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% if row.payment_method_label %} |
||||||
|
<div class="payment-method |
||||||
|
{% if row.payment_method_label == "Square" %} payment-square{% elif row.payment_method_label == "e-Transfer" %} payment-etransfer{% elif row.payment_method_label == "ETHO" %} payment-etho{% elif row.payment_method_label == "ETI" or row.payment_method_label == "EGAZ" %} payment-etica{% elif row.payment_method_label == "ALT" %} payment-alt{% elif row.payment_method_label == "CAD" %} payment-cad{% endif %}"> |
||||||
|
{{ row.payment_method_label }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { window.location.reload(); }, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
<script src="/static/brand.js" defer></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Forgot Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } .portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Reset Portal Password</h1> <p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> {% if error %} <div class="portal-msg">{{ error }}</div> {% endif %} {% if message %} <div class="portal-msg">{{ message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/forgot-password"> <div> <label for="email">Email Address</label> <input id="email" name="email" type="email" value="{{ form_email or '' }}" required> </div> <div class="portal-actions"> <button class="portal-btn" type="submit">Send Reset Code</button> <a class="portal-btn" href="/portal">Back to Portal Login</a> <a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support" href="mailto:support@outsidethebox.top">Customer Support</a> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
<script src="/static/brand.js" defer></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,65 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1 class="portal-page-title">OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn primary" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
<script src="/static/brand.js" defer></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Set Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Create Your Portal Password</h1> <p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> {% if portal_message %} <div class="portal-msg">{{ portal_message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/set-password"> <div> <label for="password">New Password</label> <input id="password" name="password" type="password" required> </div> <div> <label for="password2">Confirm Password</label> <input id="password2" name="password2" type="password" required> </div> <div> <button class="portal-btn" type="submit">Set Password</button> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>Billing base currency: 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Methods: Credit Card (via Square), e-Transfer, and enabled crypto assets</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
{% include "footer.html" %} |
||||||
|
<script src="/static/brand.js" defer></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
<header class="site-header"> |
||||||
|
<div class="site-container"> |
||||||
|
<div class="site-nav"> |
||||||
|
<a class="site-brand" href="https://outsidethebox.top"> |
||||||
|
<img src="https://outsidethebox.top/assets/favicon.png" alt="outsidethebox.top logo" /> |
||||||
|
<div class="site-title"> |
||||||
|
<strong>outsidethebox.top</strong> |
||||||
|
<span>Managed hosting • no client server logins</span> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
|
||||||
|
<div class="site-nav-right"> |
||||||
|
<nav class="site-navlinks"> |
||||||
|
<a href="https://outsidethebox.top">Home</a> |
||||||
|
<a href="https://outsidethebox.top/pricing.html">Pricing</a> |
||||||
|
<a href="https://outsidethebox.top/terms.html">ToS</a> |
||||||
|
<a href="https://outsidethebox.top/contact.html">Contact</a> |
||||||
|
<div class="dropdown"> |
||||||
|
<a href="#" class="dropdown-toggle">Services</a> |
||||||
|
<div class="dropdown-menu"> |
||||||
|
<a href="https://follow-me.outsidethebox.top">Follow-me Tracker</a> |
||||||
|
<a href="https://monitor.outsidethebox.top">Oracle</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<a href="https://otb-billing.outsidethebox.top/portal">Portal</a> |
||||||
|
</nav> |
||||||
|
|
||||||
|
<label class="otb-theme-switch" title="Toggle light / dark mode"> |
||||||
|
<input type="checkbox" id="otbThemeToggle" aria-label="Toggle theme" /> |
||||||
|
<span class="otb-theme-slider"></span> |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</header> |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,129 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Dashboard - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-wrap"> |
||||||
|
<div class="portal-page-header"> |
||||||
|
<div> |
||||||
|
<h1 class="portal-page-title">Client Dashboard</h1> |
||||||
|
<p class="portal-client-name">{{ client.company_name or client.contact_name or client.email }}</p> |
||||||
|
<p class="portal-page-subtitle">Invoices, balances, and account activity in one place.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-toolbar"> |
||||||
|
<a class="portal-btn primary" href="/portal/invoices/download-all">Download All Invoices</a> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
<a class="portal-btn" href="/portal/logout">Logout</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-grid"> |
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Invoices</h3> |
||||||
|
<div class="summary-value">{{ invoice_count }}</div> |
||||||
|
<div class="summary-sub">Invoices currently visible in your portal</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Outstanding</h3> |
||||||
|
<div class="summary-value">{{ total_outstanding }}</div> |
||||||
|
<div class="summary-sub">Current unpaid balance</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="summary-card"> |
||||||
|
<h3>Total Paid</h3> |
||||||
|
<div class="summary-value">{{ total_paid }}</div> |
||||||
|
<div class="summary-sub">Payments already applied</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h2 class="section-title">Invoices</h2> |
||||||
|
|
||||||
|
<div class="table-card"> |
||||||
|
<table class="portal-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Invoice</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Created</th> |
||||||
|
<th>Total</th> |
||||||
|
<th>Paid</th> |
||||||
|
<th>Outstanding</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for row in invoices %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a class="invoice-link" href="/portal/invoice/{{ row.id }}"> |
||||||
|
{{ row.invoice_number or ("INV-" ~ row.id) }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{% set s = (row.status or "")|lower %} |
||||||
|
{% if s == "paid" %} |
||||||
|
<span class="status-badge status-paid">{{ row.status }}</span> |
||||||
|
{% if row.payment_method_label %} |
||||||
|
<div class="payment-method |
||||||
|
{% if row.payment_method_label == "Square" %} payment-square{% elif row.payment_method_label == "e-Transfer" %} payment-etransfer{% elif row.payment_method_label == "ETHO" %} payment-etho{% elif row.payment_method_label == "ETI" or row.payment_method_label == "EGAZ" %} payment-etica{% elif row.payment_method_label == "ALT" %} payment-alt{% elif row.payment_method_label == "CAD" %} payment-cad{% endif %}"> |
||||||
|
{{ row.payment_method_label }} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
{% elif s == "pending" %} |
||||||
|
<span class="status-badge status-pending">{{ row.status }}</span> |
||||||
|
{% elif s == "overdue" %} |
||||||
|
<span class="status-badge status-overdue">{{ row.status }}</span> |
||||||
|
{% else %} |
||||||
|
<span class="status-badge status-other">{{ row.status }}</span> |
||||||
|
{% endif %} |
||||||
|
</td> |
||||||
|
<td>{{ row.created_at }}</td> |
||||||
|
<td>{{ row.total_amount }}</td> |
||||||
|
<td>{{ row.amount_paid }}</td> |
||||||
|
<td>{{ row.outstanding }}</td> |
||||||
|
</tr> |
||||||
|
{% else %} |
||||||
|
<tr> |
||||||
|
<td colspan="6">No invoices available.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(function() { |
||||||
|
setTimeout(function() { window.location.reload(); }, 20000); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>All billing is calculated in 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span> |
||||||
|
Methods: |
||||||
|
<strong>Credit Card</strong> <span style="opacity:0.7;">(via Square)</span>, |
||||||
|
<strong>e-Transfer</strong>, |
||||||
|
and <strong>enabled crypto assets</strong> |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script src="/static/brand.js" defer></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Forgot Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-card h1 { margin-top: 0; margin-bottom: 0.45rem; } .portal-sub { opacity: 0.9; margin-bottom: 1.25rem; } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; margin-top: 0.4rem; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Reset Portal Password</h1> <p class="portal-sub">Enter your email address and a new single-use access code will be sent if your account exists.</p> {% if error %} <div class="portal-msg">{{ error }}</div> {% endif %} {% if message %} <div class="portal-msg">{{ message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/forgot-password"> <div> <label for="email">Email Address</label> <input id="email" name="email" type="email" value="{{ form_email or '' }}" required> </div> <div class="portal-actions"> <button class="portal-btn" type="submit">Send Reset Code</button> <a class="portal-btn" href="/portal">Back to Portal Login</a> <a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Portal%20Support" href="mailto:support@outsidethebox.top">Customer Support</a> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>All billing is calculated in 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span> |
||||||
|
Methods: |
||||||
|
<strong>Credit Card</strong> <span style="opacity:0.7;">(via Square)</span>, |
||||||
|
<strong>e-Transfer</strong>, |
||||||
|
and <strong>enabled crypto assets</strong> |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script src="/static/brand.js" defer></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,68 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>Client Portal - OutsideTheBox</title> |
||||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} |
||||||
|
|
||||||
|
<div class="portal-shell"> |
||||||
|
<div class="portal-card"> |
||||||
|
<h1 class="portal-page-title">OutsideTheBox Client Portal</h1> |
||||||
|
<p class="portal-sub">Secure access for invoices, balances, and account information.</p> |
||||||
|
|
||||||
|
{% if portal_message %} |
||||||
|
<div class="portal-msg">{{ portal_message }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<form class="portal-form" method="post" action="/portal/login"> |
||||||
|
<div> |
||||||
|
<label for="email">Email Address</label> |
||||||
|
<input id="email" name="email" type="email" placeholder="client@example.com" value="{{ portal_email or '' }}" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="credential">Access Code or Password</label> |
||||||
|
<input id="credential" name="credential" type="password" placeholder="Enter your one-time access code or password" required> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="portal-actions"> |
||||||
|
<button class="portal-btn primary" type="submit">Sign In</button> |
||||||
|
<a class="portal-btn" href="mailto:support@outsidethebox.top?subject=Customer%20Support">Customer Support</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
<div style="margin-top:15px;"> |
||||||
|
<a href="/portal/forgot-password">Forgot your password?</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="portal-note"> |
||||||
|
First-time users should sign in with the one-time access code provided by OutsideTheBox, then set a password. |
||||||
|
This access code is single-use and is cleared after password setup. Future logins use your email address and password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>All billing is calculated in 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span> |
||||||
|
Methods: |
||||||
|
<strong>Credit Card</strong> <span style="opacity:0.7;">(via Square)</span>, |
||||||
|
<strong>e-Transfer</strong>, |
||||||
|
and <strong>enabled crypto assets</strong> |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script src="/static/brand.js" defer></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Set Portal Password - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css"> <style> .portal-wrap { max-width: 760px; margin: 2.5rem auto; padding: 1.5rem; } .portal-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 1.4rem; background: rgba(255,255,255,0.03); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } .portal-form { display: grid; gap: 0.9rem; } .portal-form label { display: block; font-weight: 600; margin-bottom: 0.35rem; } .portal-form input { width: 100%; padding: 0.8rem 0.9rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.05); color: inherit; box-sizing: border-box; } .portal-btn { display: inline-block; padding: 0.8rem 1rem; border-radius: 10px; text-decoration: none; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; } .portal-msg { margin-bottom: 1rem; padding: 0.85rem 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); } </style> <link rel="icon" type="image/png" href="/static/favicon.png"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-card"> <h1>Create Your Portal Password</h1> <p>Welcome, {{ client_name }}. Your one-time access code worked. Please create a password for future logins.</p> {% if portal_message %} <div class="portal-msg">{{ portal_message }}</div> {% endif %} <form class="portal-form" method="post" action="/portal/set-password"> <div> <label for="password">New Password</label> <input id="password" name="password" type="password" required> </div> <div> <label for="password2">Confirm Password</label> <input id="password2" name="password2" type="password" required> </div> <div> <button class="portal-btn" type="submit">Set Password</button> </div> </form> </div> |
||||||
|
</div> |
||||||
|
<div class="otb-statusbar"> |
||||||
|
<div class="otb-statusbar-inner"> |
||||||
|
<strong>All billing is calculated in 🇨🇦 CAD</strong> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span>Crypto conversions use the <a href="https://monitor.outsidethebox.top" target="_blank" rel="noopener noreferrer">OTB Oracle</a></span> |
||||||
|
<span class="otb-dot"></span> |
||||||
|
<span> |
||||||
|
Methods: |
||||||
|
<strong>Credit Card</strong> <span style="opacity:0.7;">(via Square)</span>, |
||||||
|
<strong>e-Transfer</strong>, |
||||||
|
and <strong>enabled crypto assets</strong> |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script src="/static/brand.js" defer></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
<header class="site-header"> |
||||||
|
<div class="site-container"> |
||||||
|
<div class="site-nav"> |
||||||
|
<a class="site-brand" href="https://outsidethebox.top"> |
||||||
|
<img src="https://outsidethebox.top/assets/favicon.png" alt="outsidethebox.top logo" /> |
||||||
|
<div class="site-title"> |
||||||
|
<strong>outsidethebox.top</strong> |
||||||
|
<span>Managed hosting • no client server logins</span> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
|
||||||
|
<div class="site-nav-right"> |
||||||
|
<nav class="site-navlinks"> |
||||||
|
<a href="https://outsidethebox.top">Home</a> |
||||||
|
<a href="https://outsidethebox.top/pricing.html">Pricing</a> |
||||||
|
<a href="https://outsidethebox.top/terms.html">ToS</a> |
||||||
|
<a href="https://outsidethebox.top/contact.html">Contact</a> |
||||||
|
<div class="dropdown"> |
||||||
|
<a href="#" class="dropdown-toggle">Services</a> |
||||||
|
<div class="dropdown-menu"> |
||||||
|
<a href="https://follow-me.outsidethebox.top">Follow-me Tracker</a> |
||||||
|
<a href="https://monitor.outsidethebox.top">Oracle</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<a href="https://otb-billing.outsidethebox.top/portal">Portal</a> |
||||||
|
</nav> |
||||||
|
|
||||||
|
<label class="otb-theme-switch" title="Toggle light / dark mode"> |
||||||
|
<input type="checkbox" id="otbThemeToggle" aria-label="Toggle theme" /> |
||||||
|
<span class="otb-theme-slider"></span> |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</header> |
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,91 @@ |
|||||||
|
[2026-03-14T16:31:25.037816] invoice_reminder_worker starting |
||||||
|
[2026-03-14T16:31:25.042017] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-03-15T09:00:13.715512] invoice_reminder_worker starting |
||||||
|
[2026-03-15T09:00:13.720030] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-03-16T09:00:14.708047] invoice_reminder_worker starting |
||||||
|
[2026-03-16T09:00:14.712832] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-03-17T09:00:14.931911] invoice_reminder_worker starting |
||||||
|
[2026-03-17T09:00:14.936172] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-03-18T09:00:15.000417] invoice_reminder_worker starting |
||||||
|
Traceback (most recent call last): |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 123, in <module> |
||||||
|
main() |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 98, in main |
||||||
|
{recalc_invoice_totals(inv['id'])['total']} |
||||||
|
TypeError: 'NoneType' object is not subscriptable |
||||||
|
[2026-03-19T09:00:15.181181] invoice_reminder_worker starting |
||||||
|
Traceback (most recent call last): |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 123, in <module> |
||||||
|
main() |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 98, in main |
||||||
|
{recalc_invoice_totals(inv['id'])['total']} |
||||||
|
TypeError: 'NoneType' object is not subscriptable |
||||||
|
[2026-03-20T09:00:14.989596] invoice_reminder_worker starting |
||||||
|
Traceback (most recent call last): |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 123, in <module> |
||||||
|
main() |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 98, in main |
||||||
|
{recalc_invoice_totals(inv['id'])['total']} |
||||||
|
TypeError: 'NoneType' object is not subscriptable |
||||||
|
[2026-03-21T09:00:15.739604] invoice_reminder_worker starting |
||||||
|
Traceback (most recent call last): |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 123, in <module> |
||||||
|
main() |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 98, in main |
||||||
|
{recalc_invoice_totals(inv['id'])['total']} |
||||||
|
TypeError: 'NoneType' object is not subscriptable |
||||||
|
[2026-03-22T09:00:13.788887] invoice_reminder_worker starting |
||||||
|
Traceback (most recent call last): |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 123, in <module> |
||||||
|
main() |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 98, in main |
||||||
|
{recalc_invoice_totals(inv['id'])['total']} |
||||||
|
TypeError: 'NoneType' object is not subscriptable |
||||||
|
[2026-03-23T09:00:13.812301] invoice_reminder_worker starting |
||||||
|
Traceback (most recent call last): |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 123, in <module> |
||||||
|
main() |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 98, in main |
||||||
|
{recalc_invoice_totals(inv['id'])['total']} |
||||||
|
TypeError: 'NoneType' object is not subscriptable |
||||||
|
[2026-03-24T09:00:13.771598] invoice_reminder_worker starting |
||||||
|
Traceback (most recent call last): |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 123, in <module> |
||||||
|
main() |
||||||
|
File "/home/def/otb_billing/scripts/invoice_reminder_worker.py", line 98, in main |
||||||
|
{recalc_invoice_totals(inv['id'])['total']} |
||||||
|
TypeError: 'NoneType' object is not subscriptable |
||||||
|
[2026-03-25T09:00:13.760118] invoice_reminder_worker starting |
||||||
|
[2026-03-25T09:00:13.764319] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-03-26T09:00:13.774724] invoice_reminder_worker starting |
||||||
|
[2026-03-26T09:00:13.778712] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-03-27T09:00:13.773589] invoice_reminder_worker starting |
||||||
|
[2026-03-27T09:00:13.778961] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-03-28T09:00:13.783764] invoice_reminder_worker starting |
||||||
|
[2026-03-28T09:00:13.787845] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-03-29T09:00:13.762998] invoice_reminder_worker starting |
||||||
|
[2026-03-29T09:00:13.768366] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-03-30T09:00:14.699862] invoice_reminder_worker starting |
||||||
|
[2026-03-30T09:00:14.703938] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-03-31T09:00:14.573494] invoice_reminder_worker starting |
||||||
|
[2026-03-31T09:00:14.577931] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-04-01T09:00:14.690808] invoice_reminder_worker starting |
||||||
|
[2026-04-01T09:00:14.695185] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-04-02T09:00:14.626933] invoice_reminder_worker starting |
||||||
|
[2026-04-02T09:00:14.631353] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-04-03T09:00:14.702593] invoice_reminder_worker starting |
||||||
|
[2026-04-03T09:00:14.708129] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-04-04T09:00:14.832769] invoice_reminder_worker starting |
||||||
|
[2026-04-04T09:00:14.837022] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-04-05T09:00:15.412774] invoice_reminder_worker starting |
||||||
|
[2026-04-05T09:00:15.429713] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-04-06T09:00:13.997050] invoice_reminder_worker starting |
||||||
|
[2026-04-06T09:00:14.004077] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-04-07T09:00:13.691922] invoice_reminder_worker starting |
||||||
|
[2026-04-07T09:00:13.696346] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-04-08T09:00:13.765715] invoice_reminder_worker starting |
||||||
|
[2026-04-08T09:00:13.770314] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-04-09T09:00:14.898994] invoice_reminder_worker starting |
||||||
|
[2026-04-09T09:00:14.906306] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
|
[2026-04-10T09:00:14.318241] invoice_reminder_worker starting |
||||||
|
[2026-04-10T09:00:14.322748] checked=0 reminders_sent=0 overdue_sent=0 skipped=0 |
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue