Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/apps/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
path("terms-of-service/", views.TermsOfServiceView.as_view(), name="terms_of_service"),
# API
path("api/blog-feed/", views.BlogFeedView.as_view(), name="blog_feed"),
path("api/rates/usd-kes/", views.ExchangeRateView.as_view(), name="exchange_rate"),
# Dashboard
path("dashboard/", views.DashboardView.as_view(), name="dashboard"),
path("dashboard/contacts/", views.ContactSubmissionsListView.as_view(), name="dashboard_contacts"),
Expand Down
14 changes: 14 additions & 0 deletions src/apps/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,3 +578,17 @@ async def get(self, request: HttpRequest) -> JsonResponse:
except Exception:
logger.exception("Failed to fetch blog feed")
return JsonResponse([], safe=False)


class ExchangeRateView(View):
"""Return the current USD → KES exchange rate for client-side currency display."""

async def get(self, request: HttpRequest) -> JsonResponse:
from apps.payments.currency_service import get_exchange_rate

try:
rate = await get_exchange_rate("USD", "KES")
return JsonResponse({"rate": float(rate), "from": "USD", "to": "KES"})
except Exception:
logger.exception("Failed to fetch exchange rate")
return JsonResponse({"error": "Rate unavailable"}, status=503)
12 changes: 8 additions & 4 deletions src/static/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -5392,9 +5392,17 @@ html:has(.drawer-toggle:checked) {
.bg-error\/20 {
background-color: var(--fallback-er,oklch(var(--er)/0.2));
}
.bg-info-content {
--tw-bg-opacity: 1;
background-color: var(--fallback-inc,oklch(var(--inc)/var(--tw-bg-opacity, 1)));
}
.bg-info\/10 {
background-color: var(--fallback-in,oklch(var(--in)/0.1));
}
.bg-neutral-content {
--tw-bg-opacity: 1;
background-color: var(--fallback-nc,oklch(var(--nc)/var(--tw-bg-opacity, 1)));
}
.bg-neutral-content\/10 {
background-color: var(--fallback-nc,oklch(var(--nc)/0.1));
}
Expand Down Expand Up @@ -5443,10 +5451,6 @@ html:has(.drawer-toggle:checked) {
--tw-bg-opacity: 1;
background-color: var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity, 1)));
}
.bg-warning-content {
--tw-bg-opacity: 1;
background-color: var(--fallback-wac,oklch(var(--wac)/var(--tw-bg-opacity, 1)));
}
.bg-warning\/10 {
background-color: var(--fallback-wa,oklch(var(--wa)/0.1));
}
Expand Down
147 changes: 147 additions & 0 deletions src/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ document.addEventListener("DOMContentLoaded", () => {

// Blog posts from Substack RSS
initBlogLoader();

// Currency display (USD default, KES for Kenya)
initCurrencyDisplay();
});

/**
Expand Down Expand Up @@ -200,4 +203,148 @@ function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}

// ---------------------------------------------------------------------------
// Currency display — USD default, KES for visitors in Kenya
// ---------------------------------------------------------------------------

const RATE_CACHE_KEY = "acoruss_usd_kes_rate";
const RATE_CACHE_TTL = 3600000; // 1 hour in ms

function isKenyaTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone === "Africa/Nairobi";
} catch {
return false;
}
}

function getCachedRate() {
try {
const raw = sessionStorage.getItem(RATE_CACHE_KEY);
if (!raw) return null;
const cached = JSON.parse(raw);
if (Date.now() - cached.fetchedAt < RATE_CACHE_TTL) return cached.rate;
sessionStorage.removeItem(RATE_CACHE_KEY);
} catch {
// Ignore parse errors
}
return null;
}

function setCachedRate(rate) {
try {
sessionStorage.setItem(
RATE_CACHE_KEY,
JSON.stringify({ rate, fetchedAt: Date.now() })
);
} catch {
// sessionStorage unavailable (private browsing, etc.)
}
}

function formatCurrency(amount, currency, locale) {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}

function applyPrices(currency, rate, locale) {
document.querySelectorAll("[data-price]").forEach((el) => {
const usdAmount = parseFloat(el.dataset.price);
if (isNaN(usdAmount)) return;
const amount = currency === "KES" ? Math.round(usdAmount * rate) : usdAmount;
el.textContent = formatCurrency(amount, currency, locale);
});

// Handle range prices: data-price-min / data-price-max
document.querySelectorAll("[data-price-min]").forEach((el) => {
const minUsd = parseFloat(el.dataset.priceMin);
const maxUsd = parseFloat(el.dataset.priceMax);
const suffix = el.dataset.priceSuffix || "";
if (isNaN(minUsd)) return;

const min = currency === "KES" ? Math.round(minUsd * rate) : minUsd;
const formattedMin = formatCurrency(min, currency, locale);

if (!isNaN(maxUsd)) {
const max = currency === "KES" ? Math.round(maxUsd * rate) : maxUsd;
const formattedMax = formatCurrency(max, currency, locale);
el.textContent = formattedMin + " – " + formattedMax + suffix;
} else {
el.textContent = formattedMin + suffix;
}
});
}

// Current state for toggle support
let _currentCurrency = "USD";
let _kesRate = null;

function updateToggleButtons(currency) {
const btnUsd = document.getElementById("btn-usd");
const btnKes = document.getElementById("btn-kes");
if (!btnUsd || !btnKes) return;
if (currency === "USD") {
btnUsd.classList.add("btn-primary");
btnUsd.classList.remove("btn-ghost");
btnKes.classList.add("btn-ghost");
btnKes.classList.remove("btn-primary");
} else {
btnKes.classList.add("btn-primary");
btnKes.classList.remove("btn-ghost");
btnUsd.classList.add("btn-ghost");
btnUsd.classList.remove("btn-primary");
}
}

async function getKesRate() {
if (_kesRate) return _kesRate;
const cached = getCachedRate();
if (cached) { _kesRate = cached; return _kesRate; }
try {
const res = await fetch("/api/rates/usd-kes/");
if (!res.ok) throw new Error("Rate fetch failed");
const data = await res.json();
if (data.rate) { _kesRate = data.rate; setCachedRate(_kesRate); return _kesRate; }
} catch { /* fall through */ }
return null;
}

async function switchCurrency(currency) {
if (currency === "KES") {
const rate = await getKesRate();
if (rate) {
applyPrices("KES", rate, "en-KE");
_currentCurrency = "KES";
}
} else {
applyPrices("USD", 1, "en-US");
_currentCurrency = "USD";
}
updateToggleButtons(_currentCurrency);
}

function initCurrencyToggle() {
const btnUsd = document.getElementById("btn-usd");
const btnKes = document.getElementById("btn-kes");
if (!btnUsd || !btnKes) return;
btnUsd.addEventListener("click", () => switchCurrency("USD"));
btnKes.addEventListener("click", () => switchCurrency("KES"));
}

async function initCurrencyDisplay() {
const hasPrice = document.querySelector("[data-price], [data-price-min]");
if (!hasPrice) return;

// Always default to USD
applyPrices("USD", 1, "en-US");
_currentCurrency = "USD";

updateToggleButtons(_currentCurrency);
initCurrencyToggle();
}
12 changes: 6 additions & 6 deletions src/templates/about.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

{% block content %}
<!-- About Hero -->
<section class="pt-32 pb-20">
<section class="pt-32 pb-10">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 text-center" data-animate>
<div
class="inline-flex items-center gap-2 bg-accent/10 border border-accent/20 rounded-full px-4 py-1.5 mb-4 text-accent text-xs font-semibold uppercase tracking-wider">
Expand Down Expand Up @@ -46,7 +46,7 @@ <h1 class="text-4xl sm:text-5xl font-extrabold mb-4">About <span class="text-acc
</section>

<!-- Our Story -->
<section class="py-20 sm:py-28">
<section class="py-16 sm:py-20">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid md:grid-cols-2 gap-12 items-center">
<div data-animate>
Expand Down Expand Up @@ -77,7 +77,7 @@ <h2 class="text-3xl font-bold mb-6">Our Story</h2>
</section>

<!-- Mission, Vision, Values -->
<section class="py-20 sm:py-28">
<section class="py-16 sm:py-20">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-base-200/50 border border-base-300/30 rounded-3xl p-8 md:p-12">
<div class="grid md:grid-cols-3 gap-8">
Expand Down Expand Up @@ -137,7 +137,7 @@ <h3 class="text-xl font-bold mb-4">Our Values</h3>
</section>

<!-- Our Values Detail -->
<section class="py-20 sm:py-28">
<section class="py-16 sm:py-20">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-14" data-animate>
<h2 class="text-2xl sm:text-3xl lg:text-4xl font-bold mb-3">Our <span class="text-accent">Values</span></h2>
Expand Down Expand Up @@ -253,7 +253,7 @@ <h3 class="font-bold mb-2">Security By Default</h3>
</section>

<!-- Leadership -->
<section class="py-20 sm:py-28">
<section class="py-16 sm:py-20">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-14" data-animate>
<h2 class="text-2xl sm:text-3xl lg:text-4xl font-bold mb-3">Our <span class="text-accent">Leadership</span>
Expand Down Expand Up @@ -344,7 +344,7 @@ <h4 class="text-xl font-bold mb-1">Loice Andia</h4>
</section>

<!-- CTA -->
<section class="py-20 sm:py-28">
<section class="py-16 sm:py-20">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 text-center" data-animate>
<h2 class="text-3xl font-bold mb-4">Ready to Grow Your Business with <span
class="text-accent">Technology?</span></h2>
Expand Down
6 changes: 3 additions & 3 deletions src/templates/contact.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

{% block content %}
<!-- Contact Hero -->
<section class="pt-32 pb-20">
<section class="pt-32 pb-10">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 text-center" data-animate>
<div
class="inline-flex items-center gap-2 bg-accent/10 border border-accent/20 rounded-full px-4 py-1.5 mb-4 text-accent text-xs font-semibold uppercase tracking-wider">
Expand All @@ -23,7 +23,7 @@ <h1 class="text-4xl sm:text-5xl font-extrabold mb-4">Ready to Transform Your <sp
</section>

<!-- Contact Form + Info -->
<section class="py-20 sm:py-28">
<section class="pt-8 pb-20 sm:pt-12 sm:pb-28">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid md:grid-cols-3 gap-12">
<!-- Form -->
Expand Down Expand Up @@ -136,7 +136,7 @@ <h3 class="font-bold mb-4">Get In Touch</h3>
<div class="bg-base-200/50 border border-base-300/30 rounded-2xl p-6">
<h3 class="font-bold mb-4">Discovery Call</h3>
<p class="text-base-content/60 text-sm leading-relaxed mb-4">Our discovery process starts with a
one-time fee of <span class="font-bold text-accent">KSh 5,000</span> - fully credited
one-time fee of <span class="font-bold text-accent" data-price="50">$50</span> - fully credited
toward your project if you proceed.</p>
<p class="text-base-content/60 text-sm leading-relaxed">We'll discuss your goals, timeline, and
budget
Expand Down
2 changes: 1 addition & 1 deletion src/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ <h3 class="font-bold text-sm">Discovery Call</h3>
<p class="text-base-content/50 text-sm leading-relaxed">A 60-minute deep-dive into your needs,
constraints,
and success metrics.</p>
<p class="text-xs text-base-content/35 mt-2 italic">KSh 5,000 fee, credited if you proceed.</p>
<p class="text-xs text-base-content/35 mt-2 italic"><span data-price="50">$50</span> fee, credited if you proceed.</p>
</div>
<div class="rounded-2xl border border-base-300/30 bg-base-200/50 p-6 hover:shadow-md hover:border-accent/20 transition-all duration-300"
data-animate>
Expand Down
2 changes: 1 addition & 1 deletion src/templates/payments/pay.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ <h1 class="text-3xl sm:text-4xl font-extrabold mb-3">Make a <span class="text-ac
</label>
<select name="currency" id="currency"
class="select select-bordered w-full rounded-xl bg-base-100/50 focus:border-accent focus:ring-1 focus:ring-accent/20">
<option value="USD" selected>USD</option>
<option value="KES">KES</option>
<option value="USD">USD</option>
<option value="NGN">NGN</option>
</select>
</div>
Expand Down
Loading
Loading