Files
appcakes-builds/index.html
2026-06-11 07:51:59 +00:00

2840 lines
102 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>