2840 lines
102 KiB
HTML
2840 lines
102 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta
|
||
name="viewport"
|
||
content="viewport-fit=cover,width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"
|
||
/>
|
||
<meta name="theme-color" content="#161d1a" />
|
||
<meta name="mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-title" content="QuoteLink" />
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||
<title>QuoteLink — Instant Prices. Smart Decisions.</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link
|
||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"
|
||
rel="stylesheet"
|
||
/>
|
||
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--green: #1d5c3a;
|
||
--green-2: #2c7a4e;
|
||
--orange: #f07c1a;
|
||
--dark: #161d1a;
|
||
--light: #f4f8f6;
|
||
--surface: #ffffff;
|
||
--raised: #edf3ef;
|
||
--text: #17221d;
|
||
--muted: rgba(23, 34, 29, 0.62);
|
||
--soft: rgba(29, 92, 58, 0.1);
|
||
--red: #c0392b;
|
||
--gold: #b7791f;
|
||
--line: rgba(22, 29, 26, 0.1);
|
||
--radius: 24px;
|
||
--pill: 999px;
|
||
}
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
html {
|
||
scroll-behavior: smooth;
|
||
}
|
||
body {
|
||
margin: 0;
|
||
background: var(--light);
|
||
color: var(--text);
|
||
font-family:
|
||
'Plus Jakarta Sans',
|
||
system-ui,
|
||
-apple-system,
|
||
BlinkMacSystemFont,
|
||
'Segoe UI',
|
||
sans-serif;
|
||
}
|
||
button,
|
||
input,
|
||
select,
|
||
textarea {
|
||
font: inherit;
|
||
}
|
||
button {
|
||
cursor: pointer;
|
||
}
|
||
a {
|
||
color: inherit;
|
||
}
|
||
.mono,
|
||
.price,
|
||
.stat-value,
|
||
.num {
|
||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.app {
|
||
min-height: 100vh;
|
||
background: linear-gradient(
|
||
155deg,
|
||
#eef6f1 0%,
|
||
var(--light) 48%,
|
||
#fbf4ed 100%
|
||
);
|
||
}
|
||
.topbar {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 80;
|
||
background: var(--dark);
|
||
color: white;
|
||
padding: calc(10px + env(safe-area-inset-top)) 14px 8px;
|
||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.16);
|
||
}
|
||
.brand-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
max-width: 1240px;
|
||
margin: 0 auto;
|
||
}
|
||
.logo {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
.mark {
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: 12px;
|
||
background: linear-gradient(135deg, var(--green), var(--orange));
|
||
display: grid;
|
||
place-items: center;
|
||
font-weight: 800;
|
||
flex-shrink: 0;
|
||
}
|
||
.logo h1 {
|
||
font-size: 18px;
|
||
margin: 0;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.logo p {
|
||
margin: 1px 0 0;
|
||
color: rgba(255, 255, 255, 0.64);
|
||
font-size: 11px;
|
||
}
|
||
.network {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
white-space: nowrap;
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.75);
|
||
}
|
||
.dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: #40d982;
|
||
box-shadow: 0 0 0 4px rgba(64, 217, 130, 0.12);
|
||
}
|
||
.tabs {
|
||
max-width: 1240px;
|
||
margin: 10px auto 0;
|
||
display: flex;
|
||
gap: 8px;
|
||
overflow: auto;
|
||
padding-bottom: 2px;
|
||
scrollbar-width: none;
|
||
}
|
||
.tabs::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
.tab {
|
||
border: 0;
|
||
border-radius: var(--pill);
|
||
padding: 9px 14px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: rgba(255, 255, 255, 0.76);
|
||
white-space: nowrap;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.tab.active {
|
||
background: white;
|
||
color: var(--dark);
|
||
}
|
||
main {
|
||
max-width: 1240px;
|
||
margin: 0 auto;
|
||
padding: 16px 20px 110px;
|
||
}
|
||
.page {
|
||
display: none;
|
||
}
|
||
.page.active {
|
||
display: block;
|
||
}
|
||
.grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.cols {
|
||
grid-template-columns: repeat(12, 1fr);
|
||
}
|
||
.card {
|
||
background: var(--surface);
|
||
border-radius: var(--radius);
|
||
padding: 18px;
|
||
border: 0;
|
||
}
|
||
.card.dark {
|
||
background: linear-gradient(145deg, #17211d, #0f1512);
|
||
color: white;
|
||
}
|
||
.section-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin: 18px 0 10px;
|
||
}
|
||
.section-head h2 {
|
||
font-size: 18px;
|
||
margin: 0;
|
||
}
|
||
.section-head button,
|
||
.link {
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--green);
|
||
font-weight: 800;
|
||
}
|
||
.pill,
|
||
.chip,
|
||
.badge {
|
||
border-radius: var(--pill);
|
||
padding: 6px 10px;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
}
|
||
.chip {
|
||
border: 0;
|
||
background: var(--surface);
|
||
color: var(--muted);
|
||
white-space: nowrap;
|
||
}
|
||
.chip.active,
|
||
.pill.primary {
|
||
background: var(--green);
|
||
color: white;
|
||
}
|
||
.pill.orange {
|
||
background: rgba(240, 124, 26, 0.14);
|
||
color: #a84d08;
|
||
}
|
||
.pill.green {
|
||
background: rgba(29, 92, 58, 0.12);
|
||
color: var(--green);
|
||
}
|
||
.pill.red {
|
||
background: rgba(192, 57, 43, 0.12);
|
||
color: var(--red);
|
||
}
|
||
.pill.gray {
|
||
background: var(--raised);
|
||
color: var(--muted);
|
||
}
|
||
.badge {
|
||
background: var(--raised);
|
||
color: var(--muted);
|
||
}
|
||
.btn {
|
||
border: 0;
|
||
border-radius: var(--pill);
|
||
padding: 11px 14px;
|
||
font-weight: 800;
|
||
background: var(--green);
|
||
color: white;
|
||
min-height: 42px;
|
||
}
|
||
.btn.orange {
|
||
background: var(--orange);
|
||
}
|
||
.btn.light {
|
||
background: var(--raised);
|
||
color: var(--text);
|
||
}
|
||
.btn.danger {
|
||
background: rgba(192, 57, 43, 0.12);
|
||
color: var(--red);
|
||
}
|
||
.btn.small {
|
||
padding: 8px 10px;
|
||
min-height: 34px;
|
||
font-size: 12px;
|
||
}
|
||
.btn.block {
|
||
width: 100%;
|
||
}
|
||
.input,
|
||
.select,
|
||
.textarea {
|
||
width: 100%;
|
||
border: 0;
|
||
outline: 0;
|
||
background: var(--raised);
|
||
border-radius: 12px;
|
||
padding: 12px 14px;
|
||
color: var(--text);
|
||
min-height: 44px;
|
||
}
|
||
.textarea {
|
||
resize: vertical;
|
||
min-height: 96px;
|
||
}
|
||
.field {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
.field label {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
font-weight: 800;
|
||
}
|
||
.form-grid {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.space {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
.wrap {
|
||
flex-wrap: wrap;
|
||
}
|
||
.search-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 8px;
|
||
margin: 12px 0;
|
||
}
|
||
.search-main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
background: var(--surface);
|
||
border-radius: 24px;
|
||
padding: 8px 12px;
|
||
}
|
||
.search-main input {
|
||
border: 0;
|
||
outline: 0;
|
||
background: transparent;
|
||
flex: 1;
|
||
min-width: 90px;
|
||
}
|
||
.filters {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
}
|
||
.toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
background: var(--surface);
|
||
border-radius: 12px;
|
||
padding: 10px 12px;
|
||
font-weight: 800;
|
||
font-size: 12px;
|
||
}
|
||
.toggle input {
|
||
accent-color: var(--green);
|
||
}
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 8px;
|
||
}
|
||
.stat {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-radius: 18px;
|
||
padding: 12px;
|
||
}
|
||
.stat-value {
|
||
font-size: 20px;
|
||
font-weight: 400;
|
||
}
|
||
.stat-label {
|
||
font-size: 10px;
|
||
color: rgba(255, 255, 255, 0.65);
|
||
font-weight: 800;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.hero {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.hero-title {
|
||
font-size: 28px;
|
||
line-height: 1.06;
|
||
margin: 0;
|
||
letter-spacing: -0.05em;
|
||
}
|
||
.hero p {
|
||
color: rgba(255, 255, 255, 0.68);
|
||
margin: 6px 0 0;
|
||
}
|
||
.currency {
|
||
display: flex;
|
||
gap: 8px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
padding: 6px;
|
||
border-radius: var(--pill);
|
||
}
|
||
.currency button {
|
||
border: 0;
|
||
border-radius: var(--pill);
|
||
background: transparent;
|
||
color: white;
|
||
padding: 8px 12px;
|
||
font-weight: 800;
|
||
}
|
||
.currency button.active {
|
||
background: white;
|
||
color: var(--dark);
|
||
}
|
||
.chips {
|
||
display: flex;
|
||
gap: 8px;
|
||
overflow: auto;
|
||
padding: 3px 0 8px;
|
||
scrollbar-width: none;
|
||
}
|
||
.chips::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
.product-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
}
|
||
.product {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.product.featured {
|
||
outline: 2px solid rgba(240, 124, 26, 0.55);
|
||
}
|
||
.ribbon {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 12px;
|
||
border-radius: var(--pill);
|
||
padding: 6px 10px;
|
||
font-size: 10px;
|
||
font-weight: 900;
|
||
color: white;
|
||
background: var(--green);
|
||
z-index: 1;
|
||
}
|
||
.ribbon.orange {
|
||
background: var(--orange);
|
||
}
|
||
.product-top {
|
||
display: grid;
|
||
grid-template-columns: 54px 1fr;
|
||
gap: 12px;
|
||
align-items: start;
|
||
}
|
||
.emoji {
|
||
width: 54px;
|
||
height: 54px;
|
||
border-radius: 12px;
|
||
background: var(--raised);
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 28px;
|
||
flex-shrink: 0;
|
||
}
|
||
.supplier-link {
|
||
border: 0;
|
||
background: transparent;
|
||
padding: 0;
|
||
color: var(--green);
|
||
font-weight: 900;
|
||
text-align: left;
|
||
}
|
||
.product h3 {
|
||
margin: 3px 0 4px;
|
||
font-size: 16px;
|
||
}
|
||
.price {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.04em;
|
||
}
|
||
.alt {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.bars {
|
||
display: flex;
|
||
align-items: end;
|
||
gap: 4px;
|
||
height: 38px;
|
||
margin: 12px 0;
|
||
}
|
||
.bar {
|
||
flex: 1;
|
||
min-width: 8px;
|
||
border-radius: 4px 4px 0 0;
|
||
background: rgba(29, 92, 58, 0.22);
|
||
}
|
||
.bar:last-child {
|
||
background: var(--orange);
|
||
}
|
||
.actions {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
}
|
||
.trust {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
.stars {
|
||
color: #f4a51c;
|
||
letter-spacing: -1px;
|
||
}
|
||
.tagline {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
margin-top: 10px;
|
||
}
|
||
.mini {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
}
|
||
.ai-panel {
|
||
background:
|
||
radial-gradient(
|
||
circle at top right,
|
||
rgba(240, 124, 26, 0.25),
|
||
transparent 35%
|
||
),
|
||
linear-gradient(135deg, #14201b, #090d0b);
|
||
color: white;
|
||
border-radius: 28px;
|
||
padding: 18px;
|
||
}
|
||
.ai-input {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 8px;
|
||
margin-top: 14px;
|
||
}
|
||
.ai-input input {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
color: white;
|
||
}
|
||
.ai-output {
|
||
white-space: pre-wrap;
|
||
line-height: 1.55;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-radius: 18px;
|
||
padding: 14px;
|
||
margin-top: 12px;
|
||
color: rgba(255, 255, 255, 0.86);
|
||
}
|
||
.typing span {
|
||
display: inline-block;
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: white;
|
||
margin-right: 4px;
|
||
animation: bounce 1s infinite;
|
||
}
|
||
.typing span:nth-child(2) {
|
||
animation-delay: 0.15s;
|
||
}
|
||
.typing span:nth-child(3) {
|
||
animation-delay: 0.3s;
|
||
}
|
||
@keyframes bounce {
|
||
50% {
|
||
transform: translateY(-5px);
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
.boq-layout,
|
||
.dash-layout,
|
||
.bot-layout {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.table-wrap {
|
||
overflow: auto;
|
||
}
|
||
.table {
|
||
width: 100%;
|
||
border-collapse: separate;
|
||
border-spacing: 0 8px;
|
||
min-width: 720px;
|
||
}
|
||
.table th {
|
||
text-align: left;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.table td {
|
||
background: var(--raised);
|
||
padding: 10px;
|
||
}
|
||
.table td:first-child {
|
||
border-radius: 12px 0 0 12px;
|
||
}
|
||
.table td:last-child {
|
||
border-radius: 0 12px 12px 0;
|
||
}
|
||
.coverage {
|
||
display: flex;
|
||
gap: 5px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.coverage span {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
color: white;
|
||
font-size: 10px;
|
||
background: var(--green);
|
||
}
|
||
.coverage .miss {
|
||
background: var(--red);
|
||
}
|
||
.order-card .timeline {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 3px;
|
||
margin: 14px 0;
|
||
}
|
||
.step {
|
||
text-align: center;
|
||
font-size: 10px;
|
||
color: var(--muted);
|
||
}
|
||
.step i {
|
||
display: block;
|
||
width: 13px;
|
||
height: 13px;
|
||
border-radius: 50%;
|
||
margin: 0 auto 5px;
|
||
background: #ccd8d0;
|
||
}
|
||
.step.done i {
|
||
background: var(--green);
|
||
}
|
||
.escrow {
|
||
height: 10px;
|
||
background: var(--raised);
|
||
border-radius: var(--pill);
|
||
overflow: hidden;
|
||
}
|
||
.escrow div {
|
||
height: 100%;
|
||
background: var(--orange);
|
||
border-radius: var(--pill);
|
||
}
|
||
.project-add {
|
||
border: 2px dashed rgba(29, 92, 58, 0.28);
|
||
background: transparent;
|
||
display: grid;
|
||
place-items: center;
|
||
min-height: 220px;
|
||
text-align: center;
|
||
color: var(--green);
|
||
}
|
||
.progress {
|
||
height: 11px;
|
||
background: var(--raised);
|
||
border-radius: var(--pill);
|
||
overflow: hidden;
|
||
}
|
||
.progress div {
|
||
height: 100%;
|
||
background: var(--green);
|
||
border-radius: var(--pill);
|
||
}
|
||
.progress.over div {
|
||
background: var(--red);
|
||
}
|
||
.avatar {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
background: var(--green);
|
||
color: white;
|
||
display: grid;
|
||
place-items: center;
|
||
font-weight: 900;
|
||
flex-shrink: 0;
|
||
}
|
||
.contractor {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.map {
|
||
height: 360px;
|
||
border-radius: 28px;
|
||
background-color: #dce8df;
|
||
background-image:
|
||
linear-gradient(rgba(29, 92, 58, 0.11) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(29, 92, 58, 0.11) 1px, transparent 1px),
|
||
radial-gradient(
|
||
circle at 35% 32%,
|
||
rgba(240, 124, 26, 0.22),
|
||
transparent 22%
|
||
);
|
||
background-size:
|
||
34px 34px,
|
||
34px 34px,
|
||
100% 100%;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.pin {
|
||
position: absolute;
|
||
border: 0;
|
||
background: transparent;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
.pin b {
|
||
display: block;
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 5px 8px;
|
||
font-size: 10px;
|
||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||
white-space: nowrap;
|
||
margin-bottom: 4px;
|
||
}
|
||
.pin i {
|
||
display: block;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: var(--orange);
|
||
border: 3px solid white;
|
||
margin: auto;
|
||
}
|
||
.pin.featured i {
|
||
background: var(--green);
|
||
}
|
||
.demand {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
.demandbar {
|
||
height: 9px;
|
||
background: var(--raised);
|
||
border-radius: var(--pill);
|
||
overflow: hidden;
|
||
}
|
||
.demandbar div {
|
||
height: 100%;
|
||
border-radius: var(--pill);
|
||
background: var(--green);
|
||
}
|
||
.demandbar.hot div {
|
||
background: var(--orange);
|
||
}
|
||
.ring {
|
||
--score: 80;
|
||
width: 104px;
|
||
height: 104px;
|
||
border-radius: 50%;
|
||
background: conic-gradient(
|
||
var(--green) calc(var(--score) * 1%),
|
||
var(--raised) 0
|
||
);
|
||
display: grid;
|
||
place-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.ring > span {
|
||
width: 76px;
|
||
height: 76px;
|
||
border-radius: 50%;
|
||
background: white;
|
||
display: grid;
|
||
place-items: center;
|
||
font: 700 20px 'IBM Plex Mono';
|
||
}
|
||
.analytics {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 8px;
|
||
}
|
||
.metric {
|
||
background: var(--raised);
|
||
border-radius: 16px;
|
||
padding: 12px;
|
||
}
|
||
.metric b {
|
||
display: block;
|
||
font-size: 20px;
|
||
}
|
||
.tier {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.tier.gold {
|
||
outline: 2px solid rgba(240, 124, 26, 0.3);
|
||
}
|
||
.compare {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.compare-row {
|
||
display: grid;
|
||
grid-template-columns: 94px 1fr;
|
||
gap: 8px;
|
||
align-items: center;
|
||
font-size: 12px;
|
||
}
|
||
.compare-bars {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
.compare-bars div {
|
||
height: 9px;
|
||
border-radius: var(--pill);
|
||
background: var(--green);
|
||
}
|
||
.compare-bars div:last-child {
|
||
background: rgba(240, 124, 26, 0.45);
|
||
}
|
||
.trend-card .bars {
|
||
height: 76px;
|
||
}
|
||
.trend-meta {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
}
|
||
.empty {
|
||
display: grid;
|
||
place-items: center;
|
||
text-align: center;
|
||
padding: 34px 16px;
|
||
color: var(--muted);
|
||
}
|
||
.empty .big {
|
||
font-size: 44px;
|
||
}
|
||
.alert-card {
|
||
display: grid;
|
||
grid-template-columns: auto 1fr auto;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
.phone {
|
||
background: #0b1110;
|
||
border-radius: 34px;
|
||
padding: 14px;
|
||
min-height: 560px;
|
||
color: white;
|
||
border: 8px solid #222;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.phone-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
.messages {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding: 14px 0;
|
||
overflow: auto;
|
||
}
|
||
.bubble {
|
||
max-width: 84%;
|
||
border-radius: 18px;
|
||
padding: 10px 12px;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
.incoming {
|
||
background: #18221e;
|
||
}
|
||
.outgoing {
|
||
background: var(--green);
|
||
align-self: flex-end;
|
||
}
|
||
.botmsg {
|
||
background: rgba(240, 124, 26, 0.16);
|
||
color: #ffd6b5;
|
||
}
|
||
.phone-input {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 8px;
|
||
}
|
||
.phone-input input {
|
||
background: #19231f;
|
||
color: white;
|
||
}
|
||
.import-zone {
|
||
border: 2px dashed rgba(29, 92, 58, 0.28);
|
||
border-radius: 24px;
|
||
padding: 22px;
|
||
text-align: center;
|
||
background: rgba(255, 255, 255, 0.55);
|
||
}
|
||
.import-zone.drag {
|
||
background: rgba(29, 92, 58, 0.08);
|
||
}
|
||
.log {
|
||
max-height: 180px;
|
||
overflow: auto;
|
||
background: #101612;
|
||
color: #d7f8e4;
|
||
border-radius: 14px;
|
||
padding: 12px;
|
||
font: 12px 'IBM Plex Mono';
|
||
white-space: pre-wrap;
|
||
}
|
||
.offline,
|
||
.install {
|
||
position: fixed;
|
||
left: 20px;
|
||
right: 20px;
|
||
z-index: 100;
|
||
border-radius: 18px;
|
||
padding: 12px 14px;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
.offline {
|
||
top: 92px;
|
||
background: #ffe9df;
|
||
color: #8a2b17;
|
||
}
|
||
.install {
|
||
bottom: 20px;
|
||
background: var(--dark);
|
||
color: white;
|
||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.25);
|
||
}
|
||
.toast-wrap {
|
||
position: fixed;
|
||
right: 16px;
|
||
bottom: 16px;
|
||
display: grid;
|
||
gap: 8px;
|
||
z-index: 130;
|
||
}
|
||
.toast {
|
||
border-radius: 16px;
|
||
padding: 12px 14px;
|
||
color: white;
|
||
background: var(--green);
|
||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||
font-weight: 800;
|
||
max-width: 320px;
|
||
}
|
||
.toast.orange {
|
||
background: var(--orange);
|
||
}
|
||
.toast.red {
|
||
background: var(--red);
|
||
}
|
||
.modal-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.46);
|
||
display: none;
|
||
place-items: end center;
|
||
z-index: 120;
|
||
padding: 20px;
|
||
}
|
||
.modal-backdrop.open {
|
||
display: grid;
|
||
}
|
||
.modal {
|
||
background: var(--surface);
|
||
border-radius: 28px 28px 20px 20px;
|
||
width: min(720px, 100%);
|
||
max-height: 88vh;
|
||
overflow: auto;
|
||
}
|
||
.modal-head {
|
||
position: sticky;
|
||
top: 0;
|
||
background: var(--surface);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 18px;
|
||
border-radius: 28px 28px 0 0;
|
||
z-index: 1;
|
||
}
|
||
.modal-head.dark {
|
||
background: var(--dark);
|
||
color: white;
|
||
}
|
||
.modal-body {
|
||
padding: 0 18px 18px;
|
||
}
|
||
.close {
|
||
border: 0;
|
||
background: var(--raised);
|
||
border-radius: 12px;
|
||
width: 38px;
|
||
height: 38px;
|
||
font-weight: 900;
|
||
}
|
||
.review-stars button {
|
||
border: 0;
|
||
background: transparent;
|
||
color: #d6a437;
|
||
font-size: 20px;
|
||
padding: 2px;
|
||
}
|
||
.hidden {
|
||
display: none !important;
|
||
}
|
||
@media (min-width: 680px) {
|
||
.hero {
|
||
grid-template-columns: 1.5fr 0.9fr;
|
||
}
|
||
.search-row {
|
||
grid-template-columns: 1fr 330px;
|
||
}
|
||
.product-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
.boq-layout,
|
||
.dash-layout,
|
||
.bot-layout {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
.contractor-grid,
|
||
.cards-2 {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
.cards-3 {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
.actions {
|
||
grid-template-columns: repeat(4, 1fr);
|
||
}
|
||
.stats {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
}
|
||
@media (min-width: 980px) {
|
||
.product-grid {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
.contractor-grid {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
.cards-4 {
|
||
grid-template-columns: repeat(4, 1fr);
|
||
}
|
||
.modal-backdrop {
|
||
place-items: center;
|
||
}
|
||
.modal {
|
||
border-radius: 28px;
|
||
}
|
||
.modal-head {
|
||
border-radius: 28px 28px 0 0;
|
||
}
|
||
.hero-title {
|
||
font-size: 36px;
|
||
}
|
||
.boq-layout {
|
||
grid-template-columns: 1.05fr 0.95fr;
|
||
}
|
||
.dash-layout {
|
||
grid-template-columns: 0.85fr 1.15fr;
|
||
}
|
||
.bot-layout {
|
||
grid-template-columns: 0.78fr 1.22fr;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<header class="topbar">
|
||
<div class="brand-row">
|
||
<div class="logo">
|
||
<div class="mark">QL</div>
|
||
<div>
|
||
<h1>QuoteLink</h1>
|
||
<p>Instant Prices. Smart Decisions.</p>
|
||
</div>
|
||
</div>
|
||
<div class="network">
|
||
<span class="dot"></span><span>Harare market live</span>
|
||
</div>
|
||
</div>
|
||
<nav class="tabs" id="tabs"></nav>
|
||
</header>
|
||
<div class="offline" id="offlineBar">
|
||
<b>Offline mode</b><span>Cached data is available.</span>
|
||
</div>
|
||
<main id="main"></main>
|
||
<div class="install" id="installBanner">
|
||
<div class="row">
|
||
<div class="mark">QL</div>
|
||
<div>
|
||
<b>Install QuoteLink</b>
|
||
<div class="mini" style="color: rgba(255, 255, 255, 0.65)">
|
||
Add instant material prices to your home screen.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="btn orange small" id="installBtn">Install</button>
|
||
</div>
|
||
<div class="toast-wrap" id="toasts"></div>
|
||
</div>
|
||
<div class="modal-backdrop" id="modal" role="dialog" aria-modal="true">
|
||
<div class="modal" id="modalCard"></div>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
const ZIG = 27.5;
|
||
const tabs = [
|
||
['buyer', '🔍 Find Prices'],
|
||
['ai', '🤖 AI Assist'],
|
||
['boq', '📋 BOQ Builder'],
|
||
['orders', '📦 Orders'],
|
||
['projects', '📁 Projects'],
|
||
['contractors', '👷 Contractors'],
|
||
['near', '📍 Near Me'],
|
||
['dashboard', '📊 Supplier Dashboard'],
|
||
['trends', '📈 Price Trends'],
|
||
['locks', '🔒 Price Lock'],
|
||
['alerts', '🔔 Alerts'],
|
||
['bot', '💬 WhatsApp Bot'],
|
||
];
|
||
const suppliers = [
|
||
{
|
||
id: 'nyakambiri',
|
||
name: 'Nyakambiri Hardware',
|
||
initials: 'NH',
|
||
location: 'Mbare',
|
||
km: 2.1,
|
||
delivery: 10,
|
||
verified: true,
|
||
featured: true,
|
||
tier: 'Platinum',
|
||
rating: 4.8,
|
||
trust: 92,
|
||
phone: '+263 77 123 4567',
|
||
whatsapp: '263771234567',
|
||
desc: 'High-volume cement, steel and hardware supplier serving contractors across southern Harare.',
|
||
},
|
||
{
|
||
id: 'buildmart',
|
||
name: 'Buildmart Harare',
|
||
initials: 'BH',
|
||
location: 'Ardbennie',
|
||
km: 4.5,
|
||
delivery: 25,
|
||
verified: true,
|
||
featured: false,
|
||
tier: 'Gold',
|
||
rating: 4.5,
|
||
trust: 85,
|
||
phone: '+263 78 222 1900',
|
||
whatsapp: '263782221900',
|
||
desc: 'Verified wholesaler with strong roofing, plumbing and finishing inventory.',
|
||
},
|
||
{
|
||
id: 'crown',
|
||
name: 'Crown Hardware',
|
||
initials: 'CH',
|
||
location: 'Graniteside',
|
||
km: 6,
|
||
delivery: 5,
|
||
verified: false,
|
||
featured: false,
|
||
tier: 'Free',
|
||
rating: 4.2,
|
||
trust: 68,
|
||
phone: '+263 71 333 4455',
|
||
whatsapp: '263713334455',
|
||
desc: 'Industrial-area hardware store with competitive spot pricing on roofing and bricks.',
|
||
},
|
||
{
|
||
id: 'fastbuild',
|
||
name: 'FastBuild Supplies',
|
||
initials: 'FS',
|
||
location: 'Msasa',
|
||
km: 8.2,
|
||
delivery: 0,
|
||
verified: false,
|
||
featured: false,
|
||
tier: 'Free',
|
||
rating: 4.0,
|
||
trust: 60,
|
||
phone: '+263 73 909 1122',
|
||
whatsapp: '263739091122',
|
||
desc: 'Collection-only material yard with budget stock for small projects.',
|
||
},
|
||
];
|
||
const avg = {
|
||
'OPC Cement 50kg': 9.1,
|
||
'Roofing Sheets IBR': 18.77,
|
||
'Brick face brick': 0.28,
|
||
'Y10 Reinforcing Bar 6m': 6.6,
|
||
'Dulux Weathershield 5L': 23.17,
|
||
'Timber 4x2 2.4m': 3.35,
|
||
'PVC Pipe 110mm 6m': 14.15,
|
||
};
|
||
const catAvg = {
|
||
Cement: 9.1,
|
||
Roofing: 18.77,
|
||
Bricks: 0.28,
|
||
Steel: 6.6,
|
||
Paint: 23.17,
|
||
Timber: 3.35,
|
||
Plumbing: 14.15,
|
||
};
|
||
const histories = {
|
||
'OPC Cement 50kg': [8.4, 8.6, 8.7, 8.9, 9.0, 9.1],
|
||
'Roofing Sheets IBR': [16.9, 17.3, 17.8, 18.1, 18.8, 19.2],
|
||
'Brick face brick': [0.24, 0.25, 0.27, 0.28, 0.27, 0.26],
|
||
'Y10 Reinforcing Bar 6m': [6.9, 6.8, 6.7, 6.6, 6.5, 6.4],
|
||
'PVC Pipe 110mm 6m': [13.7, 13.8, 14.0, 14.3, 14.2, 14.15],
|
||
'Dulux Weathershield 5L': [21.5, 22.0, 22.4, 22.9, 23.5, 23.17],
|
||
'Timber 4x2 2.4m': [3.55, 3.48, 3.42, 3.38, 3.35, 3.28],
|
||
};
|
||
let products = [
|
||
p(
|
||
'p1',
|
||
'nyakambiri',
|
||
'OPC Cement 50kg',
|
||
'Cement',
|
||
8.2,
|
||
'bag',
|
||
230,
|
||
true,
|
||
25,
|
||
'🏗️',
|
||
50,
|
||
true
|
||
),
|
||
p(
|
||
'p2',
|
||
'buildmart',
|
||
'OPC Cement 50kg',
|
||
'Cement',
|
||
9.5,
|
||
'bag',
|
||
170,
|
||
true,
|
||
100,
|
||
'🏗️',
|
||
100,
|
||
false
|
||
),
|
||
p(
|
||
'p3',
|
||
'crown',
|
||
'OPC Cement 50kg',
|
||
'Cement',
|
||
10.8,
|
||
'bag',
|
||
44,
|
||
true,
|
||
50,
|
||
'🏗️',
|
||
50,
|
||
false
|
||
),
|
||
p(
|
||
'p4',
|
||
'fastbuild',
|
||
'OPC Cement 50kg',
|
||
'Cement',
|
||
9.2,
|
||
'bag',
|
||
12,
|
||
true,
|
||
0,
|
||
'🏗️',
|
||
20,
|
||
false
|
||
),
|
||
p(
|
||
'p5',
|
||
'nyakambiri',
|
||
'Roofing Sheets IBR',
|
||
'Roofing',
|
||
18.5,
|
||
'sheet',
|
||
80,
|
||
true,
|
||
25,
|
||
'🏠',
|
||
20,
|
||
true
|
||
),
|
||
p(
|
||
'p6',
|
||
'buildmart',
|
||
'Roofing Sheets IBR',
|
||
'Roofing',
|
||
20.0,
|
||
'sheet',
|
||
63,
|
||
true,
|
||
50,
|
||
'🏠',
|
||
30,
|
||
false
|
||
),
|
||
p(
|
||
'p7',
|
||
'crown',
|
||
'Roofing Sheets IBR',
|
||
'Roofing',
|
||
17.8,
|
||
'sheet',
|
||
22,
|
||
true,
|
||
5,
|
||
'🏠',
|
||
20,
|
||
false
|
||
),
|
||
p(
|
||
'p8',
|
||
'nyakambiri',
|
||
'Brick face brick',
|
||
'Bricks',
|
||
0.28,
|
||
'piece',
|
||
8000,
|
||
true,
|
||
25,
|
||
'🧱',
|
||
1000,
|
||
false
|
||
),
|
||
p(
|
||
'p9',
|
||
'crown',
|
||
'Brick face brick',
|
||
'Bricks',
|
||
0.25,
|
||
'piece',
|
||
12000,
|
||
true,
|
||
5,
|
||
'🧱',
|
||
2000,
|
||
false
|
||
),
|
||
p(
|
||
'p10',
|
||
'fastbuild',
|
||
'Brick face brick',
|
||
'Bricks',
|
||
0.3,
|
||
'piece',
|
||
3000,
|
||
true,
|
||
0,
|
||
'🧱',
|
||
1000,
|
||
false
|
||
),
|
||
p(
|
||
'p11',
|
||
'nyakambiri',
|
||
'Y10 Reinforcing Bar 6m',
|
||
'Steel',
|
||
6.8,
|
||
'length',
|
||
140,
|
||
true,
|
||
25,
|
||
'🔩',
|
||
20,
|
||
false
|
||
),
|
||
p(
|
||
'p12',
|
||
'buildmart',
|
||
'Y10 Reinforcing Bar 6m',
|
||
'Steel',
|
||
6.4,
|
||
'length',
|
||
90,
|
||
true,
|
||
50,
|
||
'🔩',
|
||
20,
|
||
false
|
||
),
|
||
p(
|
||
'p13',
|
||
'nyakambiri',
|
||
'PVC Pipe 110mm 6m',
|
||
'Plumbing',
|
||
14.5,
|
||
'length',
|
||
35,
|
||
true,
|
||
25,
|
||
'🚰',
|
||
10,
|
||
false
|
||
),
|
||
p(
|
||
'p14',
|
||
'buildmart',
|
||
'PVC Pipe 110mm 6m',
|
||
'Plumbing',
|
||
13.8,
|
||
'length',
|
||
75,
|
||
true,
|
||
50,
|
||
'🚰',
|
||
10,
|
||
false
|
||
),
|
||
p(
|
||
'p15',
|
||
'nyakambiri',
|
||
'Dulux Weathershield 5L',
|
||
'Paint',
|
||
24.0,
|
||
'tin',
|
||
18,
|
||
true,
|
||
25,
|
||
'🎨',
|
||
5,
|
||
true
|
||
),
|
||
p(
|
||
'p16',
|
||
'buildmart',
|
||
'Dulux Weathershield 5L',
|
||
'Paint',
|
||
22.5,
|
||
'tin',
|
||
24,
|
||
true,
|
||
50,
|
||
'🎨',
|
||
5,
|
||
false
|
||
),
|
||
p(
|
||
'p17',
|
||
'nyakambiri',
|
||
'Timber 4x2 2.4m',
|
||
'Timber',
|
||
3.2,
|
||
'length',
|
||
160,
|
||
true,
|
||
25,
|
||
'🪵',
|
||
20,
|
||
false
|
||
),
|
||
p(
|
||
'p18',
|
||
'fastbuild',
|
||
'Timber 4x2 2.4m',
|
||
'Timber',
|
||
3.5,
|
||
'length',
|
||
55,
|
||
true,
|
||
0,
|
||
'🪵',
|
||
20,
|
||
false
|
||
),
|
||
];
|
||
function p(
|
||
id,
|
||
supplierId,
|
||
name,
|
||
cat,
|
||
price,
|
||
unit,
|
||
qty,
|
||
inStock,
|
||
delivery,
|
||
emoji,
|
||
bulk,
|
||
featured
|
||
) {
|
||
return {
|
||
id,
|
||
supplierId,
|
||
name,
|
||
cat,
|
||
price,
|
||
unit,
|
||
qty,
|
||
inStock,
|
||
delivery,
|
||
emoji,
|
||
bulk,
|
||
featured,
|
||
};
|
||
}
|
||
let orders = [
|
||
{
|
||
id: 'ORD-1041',
|
||
product: 'OPC Cement 50kg',
|
||
supplier: 'Nyakambiri Hardware',
|
||
qty: 80,
|
||
unit: 'bags',
|
||
status: 'Pending',
|
||
delivery: true,
|
||
date: '2026-02-04',
|
||
notes: 'For foundation pour',
|
||
price: 8.2,
|
||
payment: 'ZiG',
|
||
},
|
||
{
|
||
id: 'ORD-1042',
|
||
product: 'Roofing Sheets IBR',
|
||
supplier: 'Crown Hardware',
|
||
qty: 40,
|
||
unit: 'sheets',
|
||
status: 'Quoted',
|
||
delivery: true,
|
||
date: '2026-02-09',
|
||
notes: 'Confirm gauge before payment',
|
||
price: 17.8,
|
||
payment: 'EcoCash',
|
||
},
|
||
{
|
||
id: 'ORD-1043',
|
||
product: 'Y10 Reinforcing Bar 6m',
|
||
supplier: 'Buildmart Harare',
|
||
qty: 25,
|
||
unit: 'lengths',
|
||
status: 'Confirmed',
|
||
delivery: false,
|
||
date: '2026-02-02',
|
||
notes: 'Collection by foreman',
|
||
price: 6.4,
|
||
payment: 'USD Cash',
|
||
},
|
||
];
|
||
let projects = [
|
||
{
|
||
name: 'Waterfalls 2-bed shell',
|
||
location: 'Waterfalls',
|
||
phase: 'Foundation',
|
||
budget: 6500,
|
||
spent: 2480,
|
||
start: '2026-01-12',
|
||
},
|
||
{
|
||
name: 'Greendale boundary wall',
|
||
location: 'Greendale',
|
||
phase: 'Walling',
|
||
budget: 1800,
|
||
spent: 1925,
|
||
start: '2026-01-20',
|
||
},
|
||
];
|
||
const contractors = [
|
||
{
|
||
name: 'Tawanda Moyo',
|
||
trade: 'Bricklaying',
|
||
area: 'Mbare, Highfield',
|
||
skills: ['Face brick', 'Boundary walls', 'Plaster prep'],
|
||
years: 12,
|
||
jobs: 88,
|
||
rating: 4.8,
|
||
rate: 28,
|
||
verified: true,
|
||
},
|
||
{
|
||
name: 'Rudo Nyathi',
|
||
trade: 'Roofing',
|
||
area: 'Msasa, Greendale',
|
||
skills: ['IBR sheets', 'Trusses', 'Flashing'],
|
||
years: 9,
|
||
jobs: 64,
|
||
rating: 4.7,
|
||
rate: 35,
|
||
verified: true,
|
||
},
|
||
{
|
||
name: 'Peter Chikodzi',
|
||
trade: 'Plumbing',
|
||
area: 'Ardbennie, CBD',
|
||
skills: ['PVC', 'Bathrooms', 'Drainage'],
|
||
years: 7,
|
||
jobs: 51,
|
||
rating: 4.5,
|
||
rate: 30,
|
||
verified: true,
|
||
},
|
||
{
|
||
name: 'Farai Dube',
|
||
trade: 'Electrical',
|
||
area: 'Avondale, Milton Park',
|
||
skills: ['Tubing', 'DB boards', 'Solar ready'],
|
||
years: 10,
|
||
jobs: 72,
|
||
rating: 4.6,
|
||
rate: 40,
|
||
verified: true,
|
||
},
|
||
{
|
||
name: 'Memory Sithole',
|
||
trade: 'Painting',
|
||
area: 'Borrowdale, Mt Pleasant',
|
||
skills: ['Exterior', 'Damp seal', 'Finishes'],
|
||
years: 6,
|
||
jobs: 39,
|
||
rating: 4.4,
|
||
rate: 24,
|
||
verified: false,
|
||
},
|
||
{
|
||
name: 'Blessing Ncube',
|
||
trade: 'Bricklaying',
|
||
area: 'Ruwa, Msasa',
|
||
skills: ['Foundations', 'Slabs', 'Retaining walls'],
|
||
years: 8,
|
||
jobs: 45,
|
||
rating: 4.3,
|
||
rate: 27,
|
||
verified: true,
|
||
},
|
||
];
|
||
let alerts = [
|
||
{
|
||
type: 'Price drop',
|
||
product: 'OPC Cement 50kg',
|
||
target: 8.0,
|
||
via: 'Both',
|
||
status: 'Active',
|
||
},
|
||
{
|
||
type: 'Back in stock',
|
||
product: 'PVC Pipe 110mm 6m',
|
||
via: 'WhatsApp',
|
||
status: 'Fired',
|
||
},
|
||
];
|
||
let locks = [];
|
||
let currency = 'Both';
|
||
let category = 'All';
|
||
let sort = 'best';
|
||
let q = '';
|
||
let area = '';
|
||
let onlyDelivery = false;
|
||
let onlyVerified = false;
|
||
let boq = [
|
||
{ name: 'OPC Cement 50kg', cat: 'Cement', qty: 80 },
|
||
{ name: 'Y10 Reinforcing Bar 6m', cat: 'Steel', qty: 25 },
|
||
];
|
||
let selectedSupplier = 'nyakambiri';
|
||
let activeTrade = 'All';
|
||
let importRows = [];
|
||
let importHeaders = [];
|
||
const $ = (s) => document.querySelector(s);
|
||
const $$ = (s) => Array.from(document.querySelectorAll(s));
|
||
const main = $('#main');
|
||
function supplier(id) {
|
||
return suppliers.find((s) => s.id === id);
|
||
}
|
||
function money(n) {
|
||
return '$' + Number(n).toFixed(n < 1 ? 2 : 2);
|
||
}
|
||
function zig(n) {
|
||
return (n * ZIG).toFixed(2) + ' ZiG';
|
||
}
|
||
function fmtPrice(n) {
|
||
if (currency === 'USD') return `<div class="price">${money(n)}</div>`;
|
||
if (currency === 'ZiG') return `<div class="price">${zig(n)}</div>`;
|
||
return `<div class="price">${money(n)}</div><div class="alt">${zig(n)}</div>`;
|
||
}
|
||
function toast(msg, type = 'green') {
|
||
const el = document.createElement('div');
|
||
el.className =
|
||
'toast ' +
|
||
(type === 'red' ? 'red' : type === 'orange' ? 'orange' : '');
|
||
el.textContent = msg;
|
||
$('#toasts').appendChild(el);
|
||
setTimeout(() => {
|
||
el.style.opacity = '0';
|
||
el.style.transform = 'translateY(8px)';
|
||
}, 3000);
|
||
setTimeout(() => el.remove(), 3400);
|
||
}
|
||
function setHash(id) {
|
||
location.hash = id;
|
||
}
|
||
function initTabs() {
|
||
const nav = $('#tabs');
|
||
nav.innerHTML = tabs
|
||
.map(
|
||
(t) => `<button class="tab" data-tab="${t[0]}">${t[1]}</button>`
|
||
)
|
||
.join('');
|
||
nav.addEventListener('click', (e) => {
|
||
const b = e.target.closest('button');
|
||
if (b) setHash(b.dataset.tab);
|
||
});
|
||
}
|
||
function route() {
|
||
const id = (location.hash || '#buyer').slice(1);
|
||
$$('.tab').forEach((b) =>
|
||
b.classList.toggle('active', b.dataset.tab === id)
|
||
);
|
||
renderPage(id);
|
||
}
|
||
function page(id, html) {
|
||
main.innerHTML = `<section class="page active" id="${id}">${html}</section>`;
|
||
bindCommon();
|
||
}
|
||
function bindCommon() {
|
||
$$('[data-goto]').forEach(
|
||
(b) => (b.onclick = () => setHash(b.dataset.goto))
|
||
);
|
||
$$('[data-toast]').forEach(
|
||
(b) =>
|
||
(b.onclick = () =>
|
||
toast(b.dataset.toast, b.dataset.type || 'green'))
|
||
);
|
||
$$('[data-profile]').forEach(
|
||
(b) => (b.onclick = () => openSupplier(b.dataset.profile))
|
||
);
|
||
$$('[data-wa]').forEach(
|
||
(b) =>
|
||
(b.onclick = () =>
|
||
openContact(
|
||
b.dataset.wa,
|
||
b.dataset.msg || 'Hi QuoteLink, I need assistance.'
|
||
))
|
||
);
|
||
}
|
||
function dealBadge(prod) {
|
||
const a = avg[prod.name] || catAvg[prod.cat] || prod.price;
|
||
const pct = ((prod.price - a) / a) * 100;
|
||
if (pct <= -10)
|
||
return `<span class="pill green">🟢 ${Math.abs(pct).toFixed(0)}% below avg</span>`;
|
||
if (pct > 15)
|
||
return `<span class="pill red">🔴 ${pct.toFixed(0)}% above avg</span>`;
|
||
if (pct > 5) return `<span class="pill orange">⚠ Above market</span>`;
|
||
return `<span class="pill gray">Fair price</span>`;
|
||
}
|
||
function prediction(name) {
|
||
const h = histories[name] || [1, 1, 1, 1, 1, 1];
|
||
const d1 = h[h.length - 1] - h[h.length - 2],
|
||
d2 = h[h.length - 2] - h[h.length - 3];
|
||
if (d1 > 0 && d1 >= d2)
|
||
return `<span class="pill red">📈 Rising — buy now</span>`;
|
||
if (d1 < 0)
|
||
return `<span class="pill green">📉 Prices falling</span>`;
|
||
return `<span class="pill gray">→ Stable</span>`;
|
||
}
|
||
function trendBars(name) {
|
||
const h = histories[name] || [1, 1, 1, 1, 1, 1];
|
||
const max = Math.max(...h);
|
||
return `<div class="bars">${h.map((v) => `<div class="bar" style="height:${Math.max(16, (v / max) * 100)}%"></div>`).join('')}</div>`;
|
||
}
|
||
function cheapestIds() {
|
||
const map = {};
|
||
products
|
||
.filter((p) => p.inStock)
|
||
.forEach((p) => {
|
||
if (!map[p.name] || p.price < map[p.name].price) map[p.name] = p;
|
||
});
|
||
return new Set(Object.values(map).map((p) => p.id));
|
||
}
|
||
function filteredProducts() {
|
||
let arr = products.filter((p) => {
|
||
const s = supplier(p.supplierId);
|
||
return (
|
||
(!q ||
|
||
`${p.name} ${p.cat} ${s.name}`
|
||
.toLowerCase()
|
||
.includes(q.toLowerCase())) &&
|
||
(!area ||
|
||
s.location.toLowerCase().includes(area.toLowerCase())) &&
|
||
(category === 'All' || p.cat === category) &&
|
||
(!onlyDelivery || p.delivery > 0) &&
|
||
(!onlyVerified || s.verified)
|
||
);
|
||
});
|
||
if (sort === 'price') arr.sort((a, b) => a.price - b.price);
|
||
if (sort === 'rating')
|
||
arr.sort(
|
||
(a, b) =>
|
||
supplier(b.supplierId).rating - supplier(a.supplierId).rating
|
||
);
|
||
if (sort === 'distance')
|
||
arr.sort(
|
||
(a, b) => supplier(a.supplierId).km - supplier(b.supplierId).km
|
||
);
|
||
if (sort === 'stock') arr.sort((a, b) => b.qty - a.qty);
|
||
if (sort === 'best')
|
||
arr.sort(
|
||
(a, b) =>
|
||
a.price / (avg[a.name] || a.price) -
|
||
b.price / (avg[b.name] || b.price)
|
||
);
|
||
return arr;
|
||
}
|
||
function renderBuyer() {
|
||
const cheap = cheapestIds();
|
||
page(
|
||
'buyer',
|
||
`
|
||
<div class="hero"><section class="card dark"><div class="space"><div><h2 class="hero-title">Instant Prices.<br>Smart Decisions.</h2><p>Live construction material quotes for Harare contractors and buyers.</p></div><div class="currency"><button data-cur="USD">USD</button><button data-cur="ZiG">ZiG</button><button data-cur="Both">Both</button></div></div><div class="stats" style="margin-top:18px"><div class="stat"><div class="stat-value">${suppliers.length}</div><div class="stat-label">Suppliers</div></div><div class="stat"><div class="stat-value">${products.length}</div><div class="stat-label">Listings</div></div><div class="stat"><div class="stat-value">27.5</div><div class="stat-label">ZiG / USD</div></div></div></section><section class="card"><b>Market pulse</b><p class="mini">Cement is 10% cheaper at Nyakambiri. Roofing is rising; Crown has the strongest spot deal today.</p><button class="btn orange block" data-goto="ai">Ask AI for best deals</button></section></div>
|
||
<div class="search-row"><div class="search-main"><span>🔍</span><input id="searchQ" placeholder="Search cement, steel, supplier..." value="${q}"></div><div class="filters"><input class="input" id="areaQ" placeholder="Location area" value="${area}"><select class="select" id="sortSel"><option value="best">Best deal</option><option value="price">Price</option><option value="rating">Rating</option><option value="distance">Distance</option><option value="stock">Stock</option></select></div></div>
|
||
<div class="chips">${['All', 'Cement', 'Steel', 'Roofing', 'Paint', 'Bricks', 'Timber', 'Plumbing'].map((c) => `<button class="chip ${category === c ? 'active' : ''}" data-cat="${c}">${c}</button>`).join('')}</div>
|
||
<div class="filters" style="margin-bottom:12px"><label class="toggle"><input type="checkbox" id="delOnly" ${onlyDelivery ? 'checked' : ''}> Delivers only</label><label class="toggle"><input type="checkbox" id="verOnly" ${onlyVerified ? 'checked' : ''}> Verified suppliers only</label></div>
|
||
<div class="product-grid" id="productGrid">${filteredProducts()
|
||
.map((prod) => productCard(prod, cheap.has(prod.id)))
|
||
.join('')}</div>`
|
||
);
|
||
$('#sortSel').value = sort;
|
||
$$('.currency button').forEach((b) => {
|
||
b.classList.toggle('active', b.dataset.cur === currency);
|
||
b.onclick = () => {
|
||
currency = b.dataset.cur;
|
||
renderBuyer();
|
||
};
|
||
});
|
||
$('#searchQ').oninput = (e) => {
|
||
q = e.target.value;
|
||
renderBuyer();
|
||
};
|
||
$('#areaQ').oninput = (e) => {
|
||
area = e.target.value;
|
||
renderBuyer();
|
||
};
|
||
$('#sortSel').onchange = (e) => {
|
||
sort = e.target.value;
|
||
renderBuyer();
|
||
};
|
||
$('#delOnly').onchange = (e) => {
|
||
onlyDelivery = e.target.checked;
|
||
renderBuyer();
|
||
};
|
||
$('#verOnly').onchange = (e) => {
|
||
onlyVerified = e.target.checked;
|
||
renderBuyer();
|
||
};
|
||
$$('[data-cat]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
category = b.dataset.cat;
|
||
renderBuyer();
|
||
})
|
||
);
|
||
bindProductActions();
|
||
}
|
||
function productCard(prod, isBest) {
|
||
const s = supplier(prod.supplierId);
|
||
return `<article class="card product ${prod.featured || s.featured ? 'featured' : ''}">${isBest ? `<div class="ribbon">Best Price</div>` : ''}${prod.featured || s.featured ? `<div class="ribbon orange" style="${isBest ? 'top:48px' : ''}">Featured</div>` : ''}<div class="product-top"><div class="emoji">${prod.emoji}</div><div><button class="supplier-link" data-profile="${s.id}">${s.name}</button><h3>${prod.name}</h3><div class="mini">${prod.cat} · per ${prod.unit}</div></div></div><div style="margin-top:12px">${fmtPrice(prod.price)}</div><div class="row wrap" style="margin-top:8px">${dealBadge(prod)}${prediction(prod.name)}</div><div class="trust" style="margin-top:10px"><span class="stars">★★★★★</span><span>${s.rating} · Trust ${s.trust}%</span></div><div class="tagline"><span class="badge">${prod.qty <= 20 ? 'Only ' + prod.qty + ' left' : prod.qty + ' in stock'}</span><span class="badge">Bulk ${prod.bulk}+</span><span class="badge">${prod.delivery ? 'Delivers ' + prod.delivery + 'km' : 'Collection only'}</span>${s.verified ? `<span class="badge">Verified</span>` : ''}</div>${trendBars(prod.name)}<div class="actions"><button class="btn small" data-order="${prod.id}">Order</button><button class="btn small light" data-lock="${prod.id}">Lock Price</button><button class="btn small orange" data-wa="${s.id}" data-msg="Hi ${s.name}, I want ${prod.name} at ${money(prod.price)}.">WhatsApp</button><button class="btn small light" data-profile="${s.id}">View Profile</button></div></article>`;
|
||
}
|
||
function bindProductActions() {
|
||
$$('[data-lock]').forEach(
|
||
(b) => (b.onclick = () => openLock(b.dataset.lock))
|
||
);
|
||
$$('[data-order]').forEach(
|
||
(b) =>
|
||
(b.onclick = () =>
|
||
openOrder(products.find((p) => p.id === b.dataset.order)))
|
||
);
|
||
bindCommon();
|
||
}
|
||
async function askClaude(query, mode) {
|
||
const context = {
|
||
rate: ZIG,
|
||
suppliers,
|
||
stock: products.map((p) => ({
|
||
name: p.name,
|
||
cat: p.cat,
|
||
price: p.price,
|
||
qty: p.qty,
|
||
supplier: supplier(p.supplierId).name,
|
||
})),
|
||
marketAverages: avg,
|
||
queryType: mode,
|
||
};
|
||
const body = {
|
||
model: 'claude-sonnet-4-20250514',
|
||
max_tokens: 1000,
|
||
system:
|
||
'You are QuoteLink AI for Zimbabwe construction materials. Use the provided JSON context for current prices, suppliers, stock, market averages and ZiG exchange rate. Return practical, concise recommendations with emojis.',
|
||
messages: [
|
||
{ role: 'user', content: JSON.stringify({ context, query }) },
|
||
],
|
||
};
|
||
const key = window.ANTHROPIC_API_KEY || '';
|
||
if (!key) throw new Error('no key');
|
||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||
method: 'POST',
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
'anthropic-version': '2023-06-01',
|
||
'x-api-key': key,
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) throw new Error('api unavailable');
|
||
const data = await res.json();
|
||
return (data.content || []).map((c) => c.text || '').join('\n');
|
||
}
|
||
function aiFallback(qry) {
|
||
const best = filteredProducts()
|
||
.slice(0, 3)
|
||
.map(
|
||
(p) =>
|
||
`${p.name}: ${money(p.price)} from ${supplier(p.supplierId).name}`
|
||
)
|
||
.join('\n');
|
||
return `⚡ QuoteLink AI fallback\n\nBest value right now:\n${best}\n\nRecommendation: buy cement from Nyakambiri if you need verified delivery, and roofing from Crown if collection or short delivery works. Roofing is rising, so lock today if your project starts within 7 days.`;
|
||
}
|
||
function renderAI() {
|
||
page(
|
||
'ai',
|
||
`<section class="ai-panel"><h2 style="margin:0">QuoteLink AI Assist</h2><p class="mini" style="color:rgba(255,255,255,.66)">BOQ estimates, price advice, bundles and supplier recommendations using live marketplace context.</p><div class="chips">${['2-bed house BOQ', 'Best deals now', 'Price prediction', 'Roofing bundles', 'Best value supplier', 'Boundary wall'].map((x) => `<button class="chip" data-ai-chip="${x}">${x}</button>`).join('')}</div><div class="ai-input"><input class="input" id="aiQ" placeholder="Ask for a BOQ, trend or best deal..."><button class="btn orange" id="askAi">Ask</button></div><div class="ai-output" id="aiOut">Ask a question or tap a quick chip to start.</div></section><div class="section-head"><h2>Smart Bundles</h2><button data-goto="boq">Open BOQ</button></div><div class="grid cards-3">${[
|
||
['Foundation bundle', 'OPC Cement 50kg, Y10 bars, bricks', 1240],
|
||
['Roofing bundle', 'IBR sheets, timber, fasteners', 980],
|
||
['Plumbing starter', 'PVC pipe, fittings, sealant', 260],
|
||
]
|
||
.map(
|
||
(b) =>
|
||
`<article class="card"><span class="pill green">Bundle</span><h3>${b[0]}</h3><p class="mini">${b[1]}</p><div class="price">${money(b[2])}</div><button class="btn block" data-bundle="${b[0]}">Add to BOQ</button></article>`
|
||
)
|
||
.join('')}</div>`
|
||
);
|
||
const run = async (text) => {
|
||
const out = $('#aiOut');
|
||
out.innerHTML = `<div class="typing"><span></span><span></span><span></span> Thinking with market data...</div>`;
|
||
try {
|
||
out.textContent = await askClaude(text, 'assist');
|
||
} catch (e) {
|
||
out.textContent = aiFallback(text);
|
||
}
|
||
};
|
||
$('#askAi').onclick = () => run($('#aiQ').value || 'Best deals now');
|
||
$$('[data-ai-chip]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
$('#aiQ').value = b.dataset.aiChip;
|
||
run(b.dataset.aiChip);
|
||
})
|
||
);
|
||
$$('[data-bundle]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
if (b.dataset.bundle.includes('Foundation')) {
|
||
boq = [
|
||
...boq,
|
||
{ name: 'Brick face brick', cat: 'Bricks', qty: 3000 },
|
||
{ name: 'OPC Cement 50kg', cat: 'Cement', qty: 100 },
|
||
];
|
||
}
|
||
toast('Bundle added to BOQ');
|
||
setHash('boq');
|
||
})
|
||
);
|
||
}
|
||
function renderBOQ() {
|
||
const cats = [
|
||
'Cement',
|
||
'Steel',
|
||
'Roofing',
|
||
'Paint',
|
||
'Bricks',
|
||
'Timber',
|
||
'Plumbing',
|
||
];
|
||
page(
|
||
'boq',
|
||
`<div class="boq-layout"><section class="card"><div class="section-head" style="margin-top:0"><h2>Materials list</h2><button id="addBoq">+ Add row</button></div><textarea class="textarea" id="projectDesc" placeholder="Describe project for AI Auto-fill, e.g. 2-bed cottage foundation and roof"></textarea><div class="row wrap" style="margin:10px 0"><button class="btn orange" id="autoBoq">AI Auto-fill</button><button class="btn light" id="lockAll">Lock all prices</button><button class="btn" id="quoteAll">Get quotes from all suppliers</button></div><div id="boqRows" class="grid">${boq.map((r, i) => `<div class="row"><input class="input" data-b-name="${i}" value="${r.name}"><select class="select" data-b-cat="${i}">${cats.map((c) => `<option ${r.cat === c ? 'selected' : ''}>${c}</option>`).join('')}</select><input class="input" style="max-width:96px" inputmode="numeric" data-b-qty="${i}" value="${r.qty}"><button class="btn danger small" data-del-boq="${i}">×</button></div>`).join('')}</div></section><section class="card"><h2 style="margin-top:0">Supplier quote coverage</h2><div class="grid">${supplierQuotes()
|
||
.map(
|
||
(q) =>
|
||
`<article class="card" style="background:var(--raised)"><div class="space"><b>${q.name}</b><span class="pill ${q.diff === 0 ? 'green' : 'orange'}">${q.diff === 0 ? 'Best' : '+' + money(q.diff)}</span></div><div class="price">${money(q.total)}</div><div class="mini">${q.covered}/${boq.length} items covered</div><div class="coverage">${q.coverage.map((x) => `<span class="${x ? '' : 'miss'}">${x ? '✓' : '×'}</span>`).join('')}</div><button class="btn block orange" data-wa="${q.id}" data-msg="Hi ${q.name}, please quote this BOQ: ${boq.map((i) => i.qty + ' x ' + i.name).join(', ')}">Send BOQ on WhatsApp</button></article>`
|
||
)
|
||
.join('')}</div></section></div>`
|
||
);
|
||
bindBOQ();
|
||
}
|
||
function supplierQuotes() {
|
||
const totals = suppliers.map((s) => {
|
||
let total = 0,
|
||
covered = 0,
|
||
coverage = [];
|
||
boq.forEach((item) => {
|
||
const found = products
|
||
.filter(
|
||
(p) =>
|
||
p.supplierId === s.id && p.cat === item.cat && p.inStock
|
||
)
|
||
.sort((a, b) => a.price - b.price)[0];
|
||
coverage.push(!!found);
|
||
if (found) {
|
||
covered++;
|
||
total += found.price * Number(item.qty || 0);
|
||
}
|
||
});
|
||
return { id: s.id, name: s.name, total, covered, coverage };
|
||
});
|
||
const best = Math.min(
|
||
...totals.filter((t) => t.covered).map((t) => t.total)
|
||
);
|
||
return totals.map((t) => ({ ...t, diff: t.total - best }));
|
||
}
|
||
function bindBOQ() {
|
||
$$('[data-b-name]').forEach(
|
||
(i) =>
|
||
(i.oninput = (e) =>
|
||
(boq[e.target.dataset.bName].name = e.target.value))
|
||
);
|
||
$$('[data-b-cat]').forEach(
|
||
(i) =>
|
||
(i.onchange = (e) => {
|
||
boq[e.target.dataset.bCat].cat = e.target.value;
|
||
renderBOQ();
|
||
})
|
||
);
|
||
$$('[data-b-qty]').forEach(
|
||
(i) =>
|
||
(i.oninput = (e) =>
|
||
(boq[e.target.dataset.bQty].qty =
|
||
e.target.value.replace(/\D/g, '') || 0))
|
||
);
|
||
$$('[data-del-boq]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
boq.splice(Number(b.dataset.delBoq), 1);
|
||
renderBOQ();
|
||
})
|
||
);
|
||
$('#addBoq').onclick = () => {
|
||
boq.push({ name: '', cat: 'Cement', qty: 1 });
|
||
renderBOQ();
|
||
};
|
||
$('#autoBoq').onclick = () => {
|
||
boq = [
|
||
{ name: 'OPC Cement 50kg', cat: 'Cement', qty: 120 },
|
||
{ name: 'Brick face brick', cat: 'Bricks', qty: 5000 },
|
||
{ name: 'Y10 Reinforcing Bar 6m', cat: 'Steel', qty: 36 },
|
||
{ name: 'Roofing Sheets IBR', cat: 'Roofing', qty: 55 },
|
||
{ name: 'Timber 4x2 2.4m', cat: 'Timber', qty: 80 },
|
||
];
|
||
toast('AI materials list generated');
|
||
renderBOQ();
|
||
};
|
||
$('#lockAll').onclick = () => {
|
||
boq.forEach((item) => {
|
||
const found = products
|
||
.filter((p) => p.cat === item.cat && p.inStock)
|
||
.sort((a, b) => a.price - b.price)[0];
|
||
if (found && !locks.find((l) => l.productId === found.id))
|
||
locks.push({
|
||
productId: found.id,
|
||
duration: '24h',
|
||
expires: Date.now() + 86400000,
|
||
});
|
||
});
|
||
toast('Cheapest prices locked for BOQ');
|
||
};
|
||
$('#quoteAll').onclick = () =>
|
||
toast('BOQ sent to all suppliers', 'orange');
|
||
bindCommon();
|
||
}
|
||
function renderOrders() {
|
||
page(
|
||
'orders',
|
||
`<div class="space"><h2>Orders</h2><button class="btn orange" id="newOrder">New Order</button></div><div class="grid cards-4">${[
|
||
['Total', orders.length],
|
||
['Pending', orders.filter((o) => o.status === 'Pending').length],
|
||
[
|
||
'Confirmed',
|
||
orders.filter((o) => o.status === 'Confirmed').length,
|
||
],
|
||
[
|
||
'Escrow',
|
||
money(orders.reduce((a, o) => a + o.price * o.qty * 0.1, 0)),
|
||
],
|
||
[
|
||
'Delivered',
|
||
orders.filter((o) => o.status === 'Delivered').length,
|
||
],
|
||
]
|
||
.map(
|
||
(s) =>
|
||
`<div class="card"><div class="mini">${s[0]}</div><div class="price">${s[1]}</div></div>`
|
||
)
|
||
.join(
|
||
''
|
||
)}</div><div class="grid" style="margin-top:12px">${orders.map((o, i) => orderCard(o, i)).join('')}</div>`
|
||
);
|
||
$('#newOrder').onclick = () => openOrder();
|
||
$$('[data-status]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
orders[b.dataset.i].status = b.dataset.status;
|
||
toast('Order updated');
|
||
renderOrders();
|
||
})
|
||
);
|
||
$$('[data-cancel]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
orders[b.dataset.cancel].status = 'Cancelled';
|
||
toast('Order cancelled', 'red');
|
||
renderOrders();
|
||
})
|
||
);
|
||
bindCommon();
|
||
}
|
||
function orderCard(o, i) {
|
||
const stages = [
|
||
'Pending',
|
||
'Quoted',
|
||
'Deposit Paid',
|
||
'Confirmed',
|
||
'Delivered',
|
||
];
|
||
const idx = stages.indexOf(o.status);
|
||
return `<article class="card order-card"><div class="space"><div><b>${o.product}</b><div class="mini">${o.supplier} · ${o.qty} ${o.unit}</div></div><span class="pill ${o.status === 'Cancelled' ? 'red' : idx >= 3 ? 'green' : 'orange'}">${o.status}</span></div><div class="timeline">${stages.map((s, n) => `<div class="step ${n <= idx ? 'done' : ''}"><i></i>${s}</div>`).join('')}</div><div class="escrow"><div style="width:${o.status === 'Deposit Paid' || o.status === 'Confirmed' || o.status === 'Delivered' ? '100' : '35'}%"></div></div><p class="mini">Escrow deposit: ${money(o.price * o.qty * 0.1)} · Payment: ${o.payment}</p><div class="row wrap"><button class="btn small light" data-status="Quoted" data-i="${i}">Mark Quoted</button><button class="btn small orange" data-status="Deposit Paid" data-i="${i}">Pay 10% Deposit</button><button class="btn small" data-status="Confirmed" data-i="${i}">Confirm</button><button class="btn small light" data-status="Delivered" data-i="${i}">Mark Delivered</button><button class="btn small orange" data-wa="${suppliers[0].id}">WhatsApp supplier</button><button class="btn small danger" data-cancel="${i}">Cancel</button></div><p class="mini"><b>Notes:</b> ${o.notes || 'No notes'}</p></article>`;
|
||
}
|
||
function renderProjects() {
|
||
page(
|
||
'projects',
|
||
`<div class="space"><h2>Projects</h2><button class="btn orange" id="addProject">New Project</button></div><div class="grid cards-3">${projects
|
||
.map((pr, i) => {
|
||
const pct = Math.round((pr.spent / pr.budget) * 100);
|
||
return `<article class="card"><div class="space"><h3>${pr.name}</h3><span class="pill green">${pr.phase}</span></div><p class="mini">${pr.location} · Started ${pr.start}</p><div class="progress ${pct > 100 ? 'over' : ''}"><div style="width:${Math.min(100, pct)}%"></div></div><p class="mono">${money(pr.spent)} / ${money(pr.budget)} · ${pct}% used</p><div class="row"><button class="btn small" data-goto="buyer">Reorder materials</button><button class="btn small light" data-edit-project="${i}">Edit</button></div></article>`;
|
||
})
|
||
.join(
|
||
''
|
||
)}<button class="card project-add" id="addProject2"><div><div style="font-size:42px">+</div><b>Add new project</b></div></button></div>`
|
||
);
|
||
$('#addProject').onclick = () => openProject();
|
||
$('#addProject2').onclick = () => openProject();
|
||
$$('[data-edit-project]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => openProject(Number(b.dataset.editProject)))
|
||
);
|
||
bindCommon();
|
||
}
|
||
function renderContractors() {
|
||
const trades = [
|
||
'All',
|
||
'Bricklaying',
|
||
'Roofing',
|
||
'Plumbing',
|
||
'Electrical',
|
||
'Painting',
|
||
];
|
||
const list = contractors.filter(
|
||
(c) => activeTrade === 'All' || c.trade === activeTrade
|
||
);
|
||
page(
|
||
'contractors',
|
||
`<div class="section-head"><h2>Verified artisans</h2><button>Harare first</button></div><div class="chips">${trades.map((t) => `<button class="chip ${activeTrade === t ? 'active' : ''}" data-trade="${t}">${t}</button>`).join('')}</div><div class="grid contractor-grid">${list
|
||
.map(
|
||
(c) =>
|
||
`<article class="card contractor"><div class="row"><div class="avatar">${c.name
|
||
.split(' ')
|
||
.map((x) => x[0])
|
||
.join(
|
||
''
|
||
)}</div><div><b>${c.name} ${c.verified ? '✅' : ''}</b><div class="mini">${c.trade} · ${c.area}</div></div></div><div class="tagline">${c.skills.map((s) => `<span class="pill green">${s}</span>`).join('')}</div><div class="stats"><div><b>${c.years}</b><div class="mini">Years</div></div><div><b>${c.jobs}</b><div class="mini">Jobs</div></div><div><b>★ ${c.rating}</b><div class="mini">Rating</div></div></div><div class="space"><span class="price">${money(c.rate)}</span><span class="mini">/ day</span></div><button class="btn block orange" data-wa="nyakambiri" data-msg="Hi, I want to hire ${c.name} for ${c.trade}.">Hire via WhatsApp</button></article>`
|
||
)
|
||
.join('')}</div>`
|
||
);
|
||
$$('[data-trade]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
activeTrade = b.dataset.trade;
|
||
renderContractors();
|
||
})
|
||
);
|
||
bindCommon();
|
||
}
|
||
function renderNear() {
|
||
const demand = [
|
||
['Mbare', 'Cement', 86, '↑ High'],
|
||
['Msasa', 'Roofing', 72, '↑ High'],
|
||
['Avondale', 'Paint', 44, '→ Stable'],
|
||
['Graniteside', 'Steel', 58, '→ Stable'],
|
||
['Ruwa', 'Bricks', 39, '↓ Lower'],
|
||
];
|
||
page(
|
||
'near',
|
||
`<div class="map">${suppliers.map((s, i) => `<button class="pin ${s.featured ? 'featured' : ''}" style="left:${[28, 46, 62, 76][i]}%;top:${[52, 36, 61, 42][i]}%" data-profile="${s.id}"><b>${s.name}</b><i></i></button>`).join('')}</div><div class="section-head"><h2>Area Demand Insights</h2><button>Harare</button></div><div class="grid cards-3">${demand.map((d) => `<article class="card"><div class="space"><b>${d[0]}</b><span class="pill ${d[2] > 70 ? 'red' : 'green'}">${d[3]}</span></div><p class="mini">Top material: ${d[1]}</p><div class="demandbar ${d[2] > 70 ? 'hot' : ''}"><div style="width:${d[2]}%"></div></div></article>`).join('')}</div><div class="section-head"><h2>Nearest Suppliers</h2><button>Sorted by distance</button></div><div class="grid cards-2">${[
|
||
...suppliers,
|
||
]
|
||
.sort((a, b) => a.km - b.km)
|
||
.map(
|
||
(s) =>
|
||
`<article class="card"><div class="row"><div class="avatar">${s.initials}</div><div><b>${s.name}</b><div class="mini">${s.location} · ${s.km}km away</div></div></div><div class="tagline"><span class="badge">${s.verified ? 'Verified' : 'Unverified'}</span><span class="badge">${s.delivery ? 'Delivers ' + s.delivery + 'km' : 'Collection'}</span><span class="badge">★ ${s.rating}</span></div><div class="row"><button class="btn small orange" data-wa="${s.id}">WhatsApp</button><button class="btn small" data-goto="buyer">Order</button></div></article>`
|
||
)
|
||
.join('')}</div>`
|
||
);
|
||
bindCommon();
|
||
}
|
||
function renderDashboard() {
|
||
const s = supplier(selectedSupplier);
|
||
const mine = products.filter(
|
||
(p) => p.supplierId === selectedSupplier
|
||
);
|
||
page(
|
||
'dashboard',
|
||
`<div class="dash-layout"><section class="grid"><div class="card"><label class="field"><span>Supplier selector</span><select class="select" id="supplierSel">${suppliers.map((x) => `<option value="${x.id}" ${x.id === selectedSupplier ? 'selected' : ''}>${x.name}</option>`).join('')}</select></label></div><div class="card dark"><span class="pill orange">Featured / Verified</span><h2>Boost trust and search visibility</h2><p class="mini" style="color:rgba(255,255,255,.65)">Verified suppliers receive higher buyer conversion and price-lock preference.</p></div><div class="card"><div class="row"><div class="ring" style="--score:${s.trust}"><span>${s.trust}</span></div><div><h3>Verification & Trust</h3><div class="tagline"><span class="badge">WA connected</span><span class="badge">Docs ${s.verified ? 'verified' : 'pending'}</span><span class="badge">42 reviews</span><span class="badge">91% completion</span></div></div></div><div class="form-grid" style="margin-top:12px"><input class="input" value="${s.whatsapp}" placeholder="WhatsApp"><input class="input" value="${s.phone}" placeholder="Phone"><input class="input" value="${s.delivery}" placeholder="Delivery radius"><input class="input" type="file"><textarea class="textarea">${s.desc}</textarea><button class="btn">Submit for verification</button></div></div></section><section class="grid"><div class="grid cards-4">${[
|
||
['Products', mine.length],
|
||
['In stock', mine.filter((p) => p.inStock).length],
|
||
['Rating', s.rating],
|
||
['Price locks', locks.length],
|
||
]
|
||
.map(
|
||
(m) =>
|
||
`<div class="card"><div class="mini">${m[0]}</div><div class="price">${m[1]}</div></div>`
|
||
)
|
||
.join(
|
||
''
|
||
)}</div><div class="card"><h3>Growth Analytics</h3><div class="analytics">${[
|
||
['Views', '1,248', '+18%'],
|
||
['WhatsApp Clicks', '196', '+24%'],
|
||
['Orders', '32', '+9%'],
|
||
['Conversion', '15.7%', '+3.1%'],
|
||
]
|
||
.map(
|
||
(m) =>
|
||
`<div class="metric"><b class="mono">${m[1]}</b><span>${m[0]}</span><div class="pill green">${m[2]}</div></div>`
|
||
)
|
||
.join(
|
||
''
|
||
)}</div><h4>Competitor Price Comparison</h4><div class="compare">${mine
|
||
.slice(0, 4)
|
||
.map(
|
||
(p) =>
|
||
`<div class="compare-row"><span>${p.cat}</span><div class="compare-bars"><div style="width:${Math.min(100, (p.price / (avg[p.name] || p.price)) * 80)}%"></div><div style="width:80%"></div></div></div>`
|
||
)
|
||
.join('')}</div></div><div class="grid cards-3">${[
|
||
['Free', '$0/mo', 'Basic listing'],
|
||
['Gold', '$15/mo', 'Featured badge, analytics, competitor data'],
|
||
[
|
||
'Platinum',
|
||
'$30/mo',
|
||
'#1 search slot, bulk alerts, WA broadcasts',
|
||
],
|
||
]
|
||
.map(
|
||
(t) =>
|
||
`<article class="card tier ${t[0] === 'Gold' ? 'gold' : ''}"><h3>${t[0]}</h3><div class="price">${t[1]}</div><p class="mini">${t[2]}</p><button class="btn ${t[0] === 'Free' ? 'light' : 'orange'} small">Choose</button></article>`
|
||
)
|
||
.join(
|
||
''
|
||
)}</div></section></div><section class="card" style="margin-top:12px"><div class="section-head" style="margin-top:0"><h2>Product listings</h2><button id="addProduct">Add Product</button></div><div class="table-wrap"><table class="table"><thead><tr><th>Name</th><th>Category</th><th>USD price</th><th>ZiG</th><th>Qty</th><th>Bulk</th><th>Stock</th><th>Featured</th><th></th></tr></thead><tbody>${mine.map((prod) => `<tr><td>${prod.name}</td><td>${prod.cat}</td><td><input class="input" data-price="${prod.id}" value="${prod.price}"></td><td class="mono">${zig(prod.price)}</td><td><input class="input" data-qty="${prod.id}" value="${prod.qty}"></td><td>${prod.bulk}</td><td><button class="btn small ${prod.inStock ? '' : 'danger'}" data-stock="${prod.id}">${prod.inStock ? 'In stock' : 'Out'}</button></td><td><select class="select" data-feature="${prod.id}"><option ${!prod.featured ? 'selected' : ''}>No</option><option ${prod.featured ? 'selected' : ''}>Yes</option></select></td><td><button class="btn danger small" data-del-prod="${prod.id}">Delete</button></td></tr>`).join('')}</tbody></table></div><h3>Add Product form</h3><div class="form-grid" style="grid-template-columns:repeat(auto-fit,minmax(150px,1fr))"><input class="input" id="npName" placeholder="Name"><input class="input" id="npCat" placeholder="Category"><input class="input" id="npPrice" placeholder="Price"><input class="input" id="npUnit" placeholder="Unit"><input class="input" id="npQty" placeholder="Qty"><input class="input" id="npBulk" placeholder="Bulk min"><input class="input" id="npPhoto" placeholder="Photo URL"><button class="btn" id="npAdd">Add Product</button></div></section><section class="card" style="margin-top:12px"><h3>Excel / CSV / Google Sheets Import</h3>${importerHtml()}</section>`
|
||
);
|
||
bindDashboard();
|
||
}
|
||
function importerHtml() {
|
||
return `<div class="import-zone" id="dropZone"><b>Drag & drop .xlsx, .xls or .csv here</b><p class="mini">or choose a file / paste a Google Sheets CSV export URL</p><input type="file" id="filePick" accept=".xlsx,.xls,.csv"><div class="row" style="margin-top:10px"><input class="input" id="sheetUrl" placeholder="Google Sheets CSV export URL"><button class="btn small" id="fetchSheet">Fetch</button></div></div><div id="mapping"></div><div id="importLog" class="log hidden"></div><div class="row wrap" style="margin-top:10px"><button class="btn light small" id="csvTemplate">Download CSV template</button><button class="btn light small" id="xlsxTemplate">Download XLSX template</button></div>`;
|
||
}
|
||
function bindDashboard() {
|
||
$('#supplierSel').onchange = (e) => {
|
||
selectedSupplier = e.target.value;
|
||
renderDashboard();
|
||
};
|
||
$$('[data-stock]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
const pr = products.find((p) => p.id === b.dataset.stock);
|
||
pr.inStock = !pr.inStock;
|
||
if (!pr.inStock) pr.qty = 0;
|
||
toast('Stock status updated');
|
||
renderDashboard();
|
||
})
|
||
);
|
||
$$('[data-feature]').forEach(
|
||
(s) =>
|
||
(s.onchange = (e) => {
|
||
products.find(
|
||
(p) => p.id === e.target.dataset.feature
|
||
).featured = e.target.value === 'Yes';
|
||
toast('Featured setting updated', 'orange');
|
||
renderDashboard();
|
||
})
|
||
);
|
||
$$('[data-price]').forEach(
|
||
(i) =>
|
||
(i.onblur = (e) => {
|
||
products.find((p) => p.id === e.target.dataset.price).price =
|
||
Number(e.target.value) || 0;
|
||
toast('Price saved');
|
||
})
|
||
);
|
||
$$('[data-qty]').forEach(
|
||
(i) =>
|
||
(i.onblur = (e) => {
|
||
products.find((p) => p.id === e.target.dataset.qty).qty =
|
||
Number(e.target.value) || 0;
|
||
toast('Quantity saved');
|
||
})
|
||
);
|
||
$$('[data-del-prod]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
products = products.filter((p) => p.id !== b.dataset.delProd);
|
||
toast('Product deleted', 'red');
|
||
renderDashboard();
|
||
})
|
||
);
|
||
$('#npAdd').onclick = () => {
|
||
products.push(
|
||
p(
|
||
'p' + Date.now(),
|
||
selectedSupplier,
|
||
$('#npName').value || 'New material',
|
||
$('#npCat').value || 'Cement',
|
||
Number($('#npPrice').value) || 1,
|
||
$('#npUnit').value || 'unit',
|
||
Number($('#npQty').value) || 0,
|
||
true,
|
||
supplier(selectedSupplier).delivery,
|
||
'📦',
|
||
Number($('#npBulk').value) || 1,
|
||
false
|
||
)
|
||
);
|
||
toast('Product added');
|
||
renderDashboard();
|
||
};
|
||
bindImporter();
|
||
}
|
||
function bindImporter() {
|
||
const dz = $('#dropZone'),
|
||
fp = $('#filePick');
|
||
['dragenter', 'dragover'].forEach((ev) =>
|
||
dz.addEventListener(ev, (e) => {
|
||
e.preventDefault();
|
||
dz.classList.add('drag');
|
||
})
|
||
);
|
||
['dragleave', 'drop'].forEach((ev) =>
|
||
dz.addEventListener(ev, (e) => {
|
||
e.preventDefault();
|
||
dz.classList.remove('drag');
|
||
})
|
||
);
|
||
dz.addEventListener('drop', (e) =>
|
||
handleFile(e.dataTransfer.files[0])
|
||
);
|
||
fp.onchange = (e) => handleFile(e.target.files[0]);
|
||
$('#fetchSheet').onclick = async () => {
|
||
try {
|
||
const r = await fetch($('#sheetUrl').value);
|
||
parseWorkbook(await r.text(), true);
|
||
} catch (e) {
|
||
toast('Could not fetch Google Sheet', 'red');
|
||
}
|
||
};
|
||
$('#csvTemplate').onclick = () =>
|
||
download(
|
||
'quotelink-template.csv',
|
||
'name,category,price,unit,qty,bulk\nOPC Cement 50kg,Cement,8.20,bag,100,50'
|
||
);
|
||
$('#xlsxTemplate').onclick = () => {
|
||
const ws = XLSX.utils.aoa_to_sheet([
|
||
['name', 'category', 'price', 'unit', 'qty', 'bulk'],
|
||
['OPC Cement 50kg', 'Cement', 8.2, 'bag', 100, 50],
|
||
]);
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, 'Products');
|
||
XLSX.writeFile(wb, 'quotelink-template.xlsx');
|
||
};
|
||
}
|
||
function handleFile(file) {
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (e) =>
|
||
parseWorkbook(e.target.result, file.name.endsWith('.csv'));
|
||
file.name.endsWith('.csv')
|
||
? reader.readAsText(file)
|
||
: reader.readAsArrayBuffer(file);
|
||
}
|
||
function parseWorkbook(data, isCsv) {
|
||
const wb = isCsv
|
||
? XLSX.read(data, { type: 'string' })
|
||
: XLSX.read(data, { type: 'array' });
|
||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||
const arr = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
|
||
importHeaders = arr[0] || [];
|
||
importRows = arr.slice(1);
|
||
renderMapping();
|
||
}
|
||
function renderMapping() {
|
||
const auto = (k) =>
|
||
importHeaders.find((h) => String(h).toLowerCase().includes(k)) ||
|
||
'';
|
||
$('#mapping').innerHTML =
|
||
`<div class="card" style="background:var(--raised);margin-top:12px"><h4>Column mapping</h4><div class="form-grid" style="grid-template-columns:repeat(auto-fit,minmax(130px,1fr))">${['name', 'price', 'unit', 'category', 'qty'].map((k) => `<label class="field"><span>${k}</span><select class="select map" data-map="${k}">${importHeaders.map((h) => `<option ${auto(k) === h ? 'selected' : ''}>${h}</option>`).join('')}</select></label>`).join('')}</div><button class="btn" id="doImport" style="margin-top:10px">Import ${importRows.length} rows</button></div>`;
|
||
$('#doImport').onclick = doImport;
|
||
}
|
||
function doImport() {
|
||
const map = {};
|
||
$$('.map').forEach(
|
||
(s) => (map[s.dataset.map] = importHeaders.indexOf(s.value))
|
||
);
|
||
let added = 0,
|
||
updated = 0,
|
||
skipped = 0,
|
||
log = [];
|
||
importRows.forEach((r, idx) => {
|
||
const name = r[map.name];
|
||
const price = Number(r[map.price]);
|
||
if (!name || !price) {
|
||
skipped++;
|
||
log.push(`Row ${idx + 2}: skipped`);
|
||
return;
|
||
}
|
||
const ex = products.find(
|
||
(p) =>
|
||
p.name.toLowerCase() === String(name).toLowerCase() &&
|
||
p.supplierId === selectedSupplier
|
||
);
|
||
if (ex) {
|
||
ex.price = price;
|
||
ex.qty = Number(r[map.qty]) || ex.qty;
|
||
updated++;
|
||
log.push(`Row ${idx + 2}: updated ${name}`);
|
||
} else {
|
||
products.push(
|
||
p(
|
||
'p' + Date.now() + idx,
|
||
selectedSupplier,
|
||
String(name),
|
||
String(r[map.category] || 'Cement'),
|
||
price,
|
||
String(r[map.unit] || 'unit'),
|
||
Number(r[map.qty]) || 0,
|
||
true,
|
||
supplier(selectedSupplier).delivery,
|
||
'📦',
|
||
1,
|
||
false
|
||
)
|
||
);
|
||
added++;
|
||
log.push(`Row ${idx + 2}: added ${name}`);
|
||
}
|
||
});
|
||
$('#importLog').classList.remove('hidden');
|
||
$('#importLog').textContent =
|
||
`Imported: ${added}\nUpdated: ${updated}\nSkipped: ${skipped}\n\n` +
|
||
log.join('\n');
|
||
toast('Import complete');
|
||
}
|
||
function download(name, text) {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(new Blob([text], { type: 'text/csv' }));
|
||
a.download = name;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 500);
|
||
}
|
||
function renderTrends() {
|
||
page(
|
||
'trends',
|
||
`<section class="card dark"><h2>Market insights</h2><p class="mini" style="color:rgba(255,255,255,.68)">Volatility is calculated from six-month standard deviation. Rising materials should be locked before quote expiry.</p></section><div class="grid cards-3" style="margin-top:12px">${Object.keys(
|
||
histories
|
||
)
|
||
.map((n) => trendCard(n))
|
||
.join('')}</div>`
|
||
);
|
||
}
|
||
function volatility(h) {
|
||
const mean = h.reduce((a, b) => a + b, 0) / h.length;
|
||
const sd = Math.sqrt(
|
||
h.reduce((a, b) => a + (b - mean) ** 2, 0) / h.length
|
||
);
|
||
const pct = (sd / mean) * 100;
|
||
return pct < 3
|
||
? ['Stable', 'green']
|
||
: pct <= 6
|
||
? ['Variable', 'orange']
|
||
: ['Volatile', 'red'];
|
||
}
|
||
function trendCard(name) {
|
||
const h = histories[name],
|
||
v = volatility(h),
|
||
current = h[h.length - 1],
|
||
market = avg[name] || current;
|
||
return `<article class="card trend-card"><div class="space"><h3>${name}</h3><span class="pill ${v[1]}">${v[0]}</span></div><p class="mini">Market avg: ${money(market)}</p><div class="row wrap">${dealBadge({ name, cat: '', price: current })}${prediction(name)}</div>${trendBars(name)}<div class="trend-meta"><div><div class="mini">6-month range</div><b class="mono">${money(h[0])} → ${money(current)}</b></div><div><div class="mini">Historic best</div><b class="mono">${money(Math.min(...h))}</b></div></div><p class="mini">Current is ${current > market ? 'above' : 'below'} market average.</p></article>`;
|
||
}
|
||
function renderLocks() {
|
||
page(
|
||
'locks',
|
||
`<div class="space"><h2>Price Locks</h2><button class="btn" data-goto="buyer">Find Prices</button></div>${
|
||
locks.length
|
||
? `<div class="grid cards-3">${locks
|
||
.map((l, i) => {
|
||
const pr = products.find((p) => p.id === l.productId),
|
||
s = supplier(pr.supplierId);
|
||
return `<article class="card"><h3>${pr.name}</h3><p class="mini">${s.name}</p><div class="price">${money(pr.price)}</div><p class="mini">Duration: ${l.duration}<br>Expires: ${new Date(l.expires).toLocaleString()}</p><button class="btn danger small" data-remove-lock="${i}">Remove</button></article>`;
|
||
})
|
||
.join('')}</div>`
|
||
: `<div class="card empty"><div><div class="big">🔒</div><h3>No locked prices yet</h3><p>Lock a supplier price before it moves.</p><button class="btn" data-goto="buyer">Find Prices</button></div></div>`
|
||
}`
|
||
);
|
||
$$('[data-remove-lock]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
locks.splice(Number(b.dataset.removeLock), 1);
|
||
toast('Lock removed', 'red');
|
||
renderLocks();
|
||
})
|
||
);
|
||
bindCommon();
|
||
}
|
||
function renderAlerts() {
|
||
page(
|
||
'alerts',
|
||
`<div class="space"><h2>Alerts</h2><button class="btn orange" id="newAlert">New Alert</button></div><section class="card"><b>How alerts work</b><p class="mini">QuoteLink watches stock, supplier prices and new listings, then notifies you via WhatsApp, in-app or both.</p></section>${alerts.length ? `<div class="grid" style="margin-top:12px">${alerts.map((a, i) => `<article class="card alert-card"><div class="emoji">${a.type === 'Price drop' ? '📉' : a.type === 'Back in stock' ? '📦' : '🆕'}</div><div><b>${a.type}: ${a.product}</b><div class="mini">via ${a.via}${a.target ? ' · target ' + money(a.target) : ''}</div></div><div><span class="pill ${a.status === 'Active' ? 'green' : 'orange'}">${a.status}</span><button class="btn danger small" data-del-alert="${i}">Remove</button></div></article>`).join('')}</div>` : `<div class="card empty"><div><div class="big">🔔</div><h3>No alerts yet</h3><p>Track a price drop or stock return.</p><button class="btn" id="emptyAlert">Create alert</button></div></div>`}`
|
||
);
|
||
$('#newAlert').onclick = () => openAlert();
|
||
const ea = $('#emptyAlert');
|
||
if (ea) ea.onclick = () => openAlert();
|
||
$$('[data-del-alert]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
alerts.splice(Number(b.dataset.delAlert), 1);
|
||
toast('Alert removed', 'red');
|
||
renderAlerts();
|
||
})
|
||
);
|
||
}
|
||
function renderBot() {
|
||
page(
|
||
'bot',
|
||
`<div class="bot-layout"><section class="phone"><div class="phone-head"><div class="mark">QL</div><div><b>QuoteLink Bot v4</b><div class="mini" style="color:#7ee4a6">● online</div></div></div><div class="messages" id="botMessages"><div class="bubble incoming">Hi, I can check prices, deals, BOQs, orders, contacts and trends.</div><div class="bubble botmsg">Try: price cement, deal, order cement, boq 2-bed, contact, trend roofing</div></div><div class="chips">${['price cement', 'deal', 'order cement', 'boq 2-bed', 'contact', 'trend roofing'].map((c) => `<button class="chip" data-cmd="${c}">${c}</button>`).join('')}</div><div class="phone-input"><input class="input" id="botInput" placeholder="Message QuoteLink"><button class="btn orange" id="botSend">Send</button></div></section><section class="grid"><article class="card"><h3>Broadcast a Deal</h3><div class="form-grid"><input class="input" id="bdProduct" placeholder="Product"><input class="input" id="bdPrice" placeholder="Special price"><textarea class="textarea" id="bdMsg" placeholder="Message"></textarea><button class="btn orange" data-toast="Broadcast sent to subscribers" data-type="orange">Broadcast to subscribers</button></div></article><article class="card"><h3>Auto-responses</h3><label class="toggle"><input type="checkbox" checked> Auto top-3 cheapest replies</label><label class="toggle"><input type="checkbox" checked> Supplier auto-responses</label><label class="toggle"><input type="checkbox" checked> Order confirmation bot</label></article></section></div>`
|
||
);
|
||
const send = () => {
|
||
const input = $('#botInput'),
|
||
txt = input.value.trim();
|
||
if (!txt) return;
|
||
addMsg(txt, 'outgoing');
|
||
setTimeout(() => addMsg(botReply(txt), 'botmsg'), 250);
|
||
input.value = '';
|
||
};
|
||
$('#botSend').onclick = send;
|
||
$('#botInput').onkeydown = (e) => {
|
||
if (e.key === 'Enter') send();
|
||
};
|
||
$$('[data-cmd]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
$('#botInput').value = b.dataset.cmd;
|
||
send();
|
||
})
|
||
);
|
||
bindCommon();
|
||
}
|
||
function addMsg(text, cls) {
|
||
const m = $('#botMessages');
|
||
m.insertAdjacentHTML(
|
||
'beforeend',
|
||
`<div class="bubble ${cls}">${text.replace(/\n/g, '<br>')}</div>`
|
||
);
|
||
m.scrollTop = m.scrollHeight;
|
||
}
|
||
function botReply(txt) {
|
||
const t = txt.toLowerCase();
|
||
if (t.startsWith('price')) {
|
||
const mat = t.replace('price', '').trim();
|
||
return (
|
||
products
|
||
.filter(
|
||
(p) =>
|
||
p.name.toLowerCase().includes(mat) ||
|
||
p.cat.toLowerCase().includes(mat)
|
||
)
|
||
.sort((a, b) => a.price - b.price)
|
||
.slice(0, 3)
|
||
.map(
|
||
(p) =>
|
||
`${p.name}: ${money(p.price)} — ${supplier(p.supplierId).name}`
|
||
)
|
||
.join('\n') || 'No match. Try price cement.'
|
||
);
|
||
}
|
||
if (t === 'deal')
|
||
return products
|
||
.slice()
|
||
.sort(
|
||
(a, b) =>
|
||
a.price / (avg[a.name] || a.price) -
|
||
b.price / (avg[b.name] || b.price)
|
||
)
|
||
.slice(0, 3)
|
||
.map(
|
||
(p) =>
|
||
`${p.name} ${money(p.price)} (${supplier(p.supplierId).name})`
|
||
)
|
||
.join('\n');
|
||
if (t.startsWith('order'))
|
||
return 'Order flow started. Send quantity + delivery area and I will prepare a supplier WhatsApp message.';
|
||
if (t.startsWith('boq'))
|
||
return 'Rough 2-bed BOQ: cement 120 bags, bricks 5,000, Y10 bars 36, IBR sheets 55, timber 80. Estimated materials: $5,800–$6,700.';
|
||
if (t === 'contact')
|
||
return suppliers
|
||
.map(
|
||
(s) =>
|
||
`${s.name}: ${s.phone}, ${s.location}, trust ${s.trust}%, delivery ${s.delivery || 'collection'}`
|
||
)
|
||
.join('\n');
|
||
if (t.startsWith('trend'))
|
||
return 'Roofing has risen for 4 months. AI recommendation: lock Crown Hardware pricing now if you can collect quickly.';
|
||
return 'Commands: price [material], deal, order [item], boq [project], contact, trend [material].';
|
||
}
|
||
function openModal(html) {
|
||
const m = $('#modal'),
|
||
c = $('#modalCard');
|
||
c.innerHTML = html;
|
||
m.classList.add('open');
|
||
$$('.close,[data-close]').forEach((b) => (b.onclick = closeModal));
|
||
}
|
||
function closeModal() {
|
||
$('#modal').classList.remove('open');
|
||
}
|
||
$('#modal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'modal') closeModal();
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') closeModal();
|
||
});
|
||
function openSupplier(id) {
|
||
const s = supplier(id);
|
||
openModal(
|
||
`<div class="modal-head dark"><div class="row"><div class="avatar">${s.initials}</div><div><h3 style="margin:0">${s.name} ${s.verified ? '✅' : ''}</h3><div class="mini" style="color:rgba(255,255,255,.68)">${s.location} · ★ ${s.rating}</div></div></div><button class="close">×</button></div><div class="modal-body"><div class="row" style="margin:14px 0"><div class="ring" style="--score:${s.trust}"><span>${s.trust}</span></div><div><b>Trust score</b><p class="mini">Verified docs, reviews, WhatsApp response and order completion.</p></div></div><p>${s.desc}</p><div class="row"><button class="btn block orange" data-wa="${s.id}">WhatsApp</button><a class="btn block light" href="tel:${s.phone}">Call</a></div><section class="card" style="background:var(--raised);margin-top:12px"><b>Delivery info</b><p class="mini">${s.delivery ? 'Delivery radius: ' + s.delivery + 'km' : 'Collection only'} · ${s.km}km from CBD reference.</p></section><h3>Recent reviews</h3>${['On-time delivery and price matched the quote.', 'Good stock levels and quick WhatsApp response.'].map((r) => `<div class="card" style="background:var(--raised);margin-bottom:8px"><b>★★★★★</b><p class="mini">Overall 5 · On time 5 · Quality 4 · Price matched 5</p><p>${r}</p></div>`).join('')}<h3>Leave a review</h3><div class="review-stars">${['Overall', 'On time', 'Quality', 'Price matched'].map((x) => `<div class="space"><span>${x}</span><span>${[1, 2, 3, 4, 5].map((n) => `<button>★</button>`).join('')}</span></div>`).join('')}</div><textarea class="textarea" placeholder="Write your review"></textarea><button class="btn block" data-close data-toast="Review submitted">Submit review</button></div>`
|
||
);
|
||
bindCommon();
|
||
}
|
||
function openContact(id, msg) {
|
||
const s = supplier(id);
|
||
openModal(
|
||
`<div class="modal-head"><h3>WhatsApp ${s.name}</h3><button class="close">×</button></div><div class="modal-body"><p class="mini">Message preview</p><div class="card" style="background:var(--raised)">${msg}</div><a class="btn block orange" style="display:grid;place-items:center;text-decoration:none;margin-top:12px" target="_blank" href="https://wa.me/${s.whatsapp}?text=${encodeURIComponent(msg)}">Open WhatsApp</a></div>`
|
||
);
|
||
}
|
||
function openLock(id) {
|
||
const pr = products.find((p) => p.id === id);
|
||
openModal(
|
||
`<div class="modal-head"><h3>Lock Price</h3><button class="close">×</button></div><div class="modal-body"><p class="mini">${pr.name} · ${supplier(pr.supplierId).name}</p><div class="price" style="font-size:36px">${money(pr.price)}</div><div class="grid cards-3" style="margin:12px 0">${[
|
||
['24h', 'Free', ''],
|
||
['72h', '$1.00', ''],
|
||
['7 days', '$3.00 Premium', 'orange'],
|
||
]
|
||
.map(
|
||
(d, i) =>
|
||
`<button class="card ${d[2] ? 'featured' : ''}" style="text-align:left" data-duration="${d[0]}"><b>${d[0]}</b><p class="mini">${d[1]}</p></button>`
|
||
)
|
||
.join(
|
||
''
|
||
)}</div><button class="btn block" id="confirmLock">Confirm lock</button></div>`
|
||
);
|
||
let duration = '24h';
|
||
$$('[data-duration]').forEach(
|
||
(b) =>
|
||
(b.onclick = () => {
|
||
duration = b.dataset.duration;
|
||
toast(duration + ' selected', 'orange');
|
||
})
|
||
);
|
||
$('#confirmLock').onclick = () => {
|
||
const days = duration === '7 days' ? 7 : duration === '72h' ? 3 : 1;
|
||
locks.push({
|
||
productId: id,
|
||
duration,
|
||
expires: Date.now() + days * 86400000,
|
||
});
|
||
toast('Price locked');
|
||
closeModal();
|
||
renderBuyer();
|
||
};
|
||
}
|
||
function openOrder(prod) {
|
||
openModal(
|
||
`<div class="modal-head"><h3>New Order</h3><button class="close">×</button></div><div class="modal-body"><div class="form-grid"><input class="input" id="omProduct" placeholder="Product" value="${prod ? prod.name : ''}"><select class="select" id="omSupplier">${suppliers.map((s) => `<option ${prod && prod.supplierId === s.id ? 'selected' : ''}>${s.name}</option>`).join('')}</select><input class="input" id="omQty" inputmode="numeric" placeholder="Quantity"><input class="input" id="omUnit" placeholder="Unit" value="${prod ? prod.unit : ''}"><label class="toggle"><input id="omDelivery" type="checkbox" checked> Delivery required</label><input class="input" id="omDate" type="date"><textarea class="textarea" id="omNotes" placeholder="Notes"></textarea><div class="card" style="background:var(--raised)"><b>Escrow deposit</b><p class="mini">10% deposit is auto-calculated after quantity. Default estimate uses selected product price.</p><div id="dep" class="price">$0.00</div></div><select class="select" id="omPay"><option>EcoCash</option><option>USD Cash</option><option>ZiG — Cheaper</option><option>OneMoney</option></select><button class="btn block orange" id="saveOrder">Create Order</button></div></div>`
|
||
);
|
||
const price = prod ? prod.price : 10;
|
||
$('#omQty').oninput = (e) =>
|
||
($('#dep').textContent = money(
|
||
(Number(e.target.value) || 0) * price * 0.1
|
||
));
|
||
$('#saveOrder').onclick = () => {
|
||
orders.unshift({
|
||
id: 'ORD-' + (1044 + orders.length),
|
||
product: $('#omProduct').value,
|
||
supplier: $('#omSupplier').value,
|
||
qty: Number($('#omQty').value) || 1,
|
||
unit: $('#omUnit').value || 'unit',
|
||
status: 'Pending',
|
||
delivery: $('#omDelivery').checked,
|
||
date: $('#omDate').value,
|
||
notes: $('#omNotes').value,
|
||
price,
|
||
payment: $('#omPay').value,
|
||
});
|
||
toast('Order created');
|
||
closeModal();
|
||
setHash('orders');
|
||
};
|
||
}
|
||
function openProject(i) {
|
||
const pr = Number.isInteger(i)
|
||
? projects[i]
|
||
: {
|
||
name: '',
|
||
location: '',
|
||
phase: 'Foundation',
|
||
budget: '',
|
||
start: '',
|
||
};
|
||
openModal(
|
||
`<div class="modal-head"><h3>${Number.isInteger(i) ? 'Edit' : 'New'} Project</h3><button class="close">×</button></div><div class="modal-body"><div class="form-grid"><input class="input" id="prName" placeholder="Name" value="${pr.name}"><input class="input" id="prLoc" placeholder="Location" value="${pr.location}"><select class="select" id="prPhase">${['Foundation', 'Walling', 'Roofing', 'Finishing'].map((x) => `<option ${pr.phase === x ? 'selected' : ''}>${x}</option>`).join('')}</select><input class="input" id="prBudget" inputmode="decimal" placeholder="Budget" value="${pr.budget}"><input class="input" id="prStart" type="date" value="${pr.start}"><button class="btn block" id="saveProject">Save Project</button></div></div>`
|
||
);
|
||
$('#saveProject').onclick = () => {
|
||
const obj = {
|
||
name: $('#prName').value,
|
||
location: $('#prLoc').value,
|
||
phase: $('#prPhase').value,
|
||
budget: Number($('#prBudget').value) || 0,
|
||
spent: Number.isInteger(i) ? projects[i].spent : 0,
|
||
start: $('#prStart').value,
|
||
};
|
||
Number.isInteger(i) ? (projects[i] = obj) : projects.push(obj);
|
||
toast('Project saved');
|
||
closeModal();
|
||
renderProjects();
|
||
};
|
||
}
|
||
function openAlert() {
|
||
openModal(
|
||
`<div class="modal-head"><h3>New Alert</h3><button class="close">×</button></div><div class="modal-body"><div class="form-grid"><select class="select" id="alType"><option>Price drop</option><option>Back in stock</option><option>New supplier</option></select><input class="input" id="alProduct" placeholder="Product name"><input class="input" id="alTarget" inputmode="decimal" placeholder="Target price"><select class="select" id="alVia"><option>WhatsApp</option><option>In-app</option><option>Both</option></select><button class="btn block orange" id="saveAlert">Create Alert</button></div></div>`
|
||
);
|
||
$('#alType').onchange = (e) =>
|
||
$('#alTarget').classList.toggle(
|
||
'hidden',
|
||
e.target.value !== 'Price drop'
|
||
);
|
||
$('#saveAlert').onclick = () => {
|
||
alerts.unshift({
|
||
type: $('#alType').value,
|
||
product: $('#alProduct').value,
|
||
target: Number($('#alTarget').value) || undefined,
|
||
via: $('#alVia').value,
|
||
status: 'Active',
|
||
});
|
||
toast('Alert created');
|
||
closeModal();
|
||
renderAlerts();
|
||
};
|
||
}
|
||
function renderPage(id) {
|
||
(
|
||
({
|
||
buyer: renderBuyer,
|
||
ai: renderAI,
|
||
boq: renderBOQ,
|
||
orders: renderOrders,
|
||
projects: renderProjects,
|
||
contractors: renderContractors,
|
||
near: renderNear,
|
||
dashboard: renderDashboard,
|
||
trends: renderTrends,
|
||
locks: renderLocks,
|
||
alerts: renderAlerts,
|
||
bot: renderBot,
|
||
})[id] || renderBuyer
|
||
)();
|
||
}
|
||
function online() {
|
||
const bar = $('#offlineBar');
|
||
bar.style.display = navigator.onLine ? 'none' : 'flex';
|
||
}
|
||
window.addEventListener('online', online);
|
||
window.addEventListener('offline', online);
|
||
online();
|
||
setTimeout(() => ($('#installBanner').style.display = 'flex'), 10000);
|
||
$('#installBtn').onclick = () => {
|
||
$('#installBanner').style.display = 'none';
|
||
toast('Install prompt ready when browser supports it');
|
||
};
|
||
if ('serviceWorker' in navigator) {
|
||
const sw = `self.addEventListener('install',e=>self.skipWaiting());self.addEventListener('activate',e=>self.clients.claim());self.addEventListener('fetch',e=>{});`;
|
||
const url = URL.createObjectURL(
|
||
new Blob([sw], { type: 'text/javascript' })
|
||
);
|
||
navigator.serviceWorker.register(url).catch(() => {});
|
||
}
|
||
initTabs();
|
||
window.addEventListener('hashchange', route);
|
||
route();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|