diff --git a/src/apps/core/urls.py b/src/apps/core/urls.py index cfdbb45..5fae503 100644 --- a/src/apps/core/urls.py +++ b/src/apps/core/urls.py @@ -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"), diff --git a/src/apps/core/views.py b/src/apps/core/views.py index 563b117..6b8cb5b 100644 --- a/src/apps/core/views.py +++ b/src/apps/core/views.py @@ -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) diff --git a/src/static/css/main.css b/src/static/css/main.css index ed0702c..96c467f 100644 --- a/src/static/css/main.css +++ b/src/static/css/main.css @@ -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)); } @@ -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)); } diff --git a/src/static/js/main.js b/src/static/js/main.js index 9e106b5..1d47d9b 100644 --- a/src/static/js/main.js +++ b/src/static/js/main.js @@ -21,6 +21,9 @@ document.addEventListener("DOMContentLoaded", () => { // Blog posts from Substack RSS initBlogLoader(); + + // Currency display (USD default, KES for Kenya) + initCurrencyDisplay(); }); /** @@ -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(); } \ No newline at end of file diff --git a/src/templates/about.html b/src/templates/about.html index d18f7cc..bcbf0d3 100644 --- a/src/templates/about.html +++ b/src/templates/about.html @@ -8,7 +8,7 @@ {% block content %} -
+
@@ -46,7 +46,7 @@

About +
@@ -77,7 +77,7 @@

Our Story

-
+
@@ -137,7 +137,7 @@

Our Values

-
+

Our Values

@@ -253,7 +253,7 @@

Security By Default

-
+

Our Leadership @@ -344,7 +344,7 @@

Loice Andia

-
+

Ready to Grow Your Business with Technology?

diff --git a/src/templates/contact.html b/src/templates/contact.html index 1e77c1d..a379c8e 100644 --- a/src/templates/contact.html +++ b/src/templates/contact.html @@ -8,7 +8,7 @@ {% block content %} -
+
@@ -23,7 +23,7 @@

Ready to Transform Your -
+
@@ -136,7 +136,7 @@

Get In Touch

Discovery Call

Our discovery process starts with a - one-time fee of KSh 5,000 - fully credited + one-time fee of $50 - fully credited toward your project if you proceed.

We'll discuss your goals, timeline, and budget diff --git a/src/templates/index.html b/src/templates/index.html index b831218..e2b08a9 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -486,7 +486,7 @@

Discovery Call

A 60-minute deep-dive into your needs, constraints, and success metrics.

-

KSh 5,000 fee, credited if you proceed.

+

$50 fee, credited if you proceed.

diff --git a/src/templates/payments/pay.html b/src/templates/payments/pay.html index 5042225..136585a 100644 --- a/src/templates/payments/pay.html +++ b/src/templates/payments/pay.html @@ -97,8 +97,8 @@

Make a + -

diff --git a/src/templates/pricing.html b/src/templates/pricing.html index f3d45b5..850578b 100644 --- a/src/templates/pricing.html +++ b/src/templates/pricing.html @@ -8,7 +8,7 @@ {% block content %} -
+
@@ -25,11 +25,19 @@

Transparent Pricing, Scoped Book Discovery Call

+ +
+ Show prices in: +
+ + +
+
-
+

How Our Pricing Works

@@ -123,11 +131,12 @@

What Influences Cost

-
+

"Start Here" Pricing Anchors

-

Common outcomes and their typical investment +

Common outcomes and their typical investment ranges.

+

Timelines assume all requirements, content, and assets are provided, and deposit is paid.

@@ -140,7 +149,7 @@

Launch / Marketing Websites

Timeline:~1 week
From:Ksh. 30,000 - 100,000
+ class="text-accent font-bold" data-price-min="300" data-price-max="1000">$300 – $1,000

Timeline:4-6 weeks+
From:Ksh. 80,000 - 150,000
+ class="text-accent font-bold" data-price-min="800" data-price-max="1500">$800 – $1,500
-

Timeline assumes client provides copy/images - within 7 - days.

See exact pricing @@ -180,7 +186,7 @@

E-Commerce & Bookings

Timeline:6-8 weeks+
From:Ksh. 130,000 - 500,000+
+ class="text-accent font-bold" data-price-min="1300" data-price-max="5000" data-price-suffix="+">$1,300 – $5,000+

Timeline:4-6 weeks
From:Ksh. 100,000+
+ class="text-accent font-bold" data-price-min="1000" data-price-suffix="+">$1,000+
-
+
@@ -308,7 +314,7 @@

Ongoing Support Plans

Essentials

Basic maintenance

-

From Ksh. 10,000$100/month

  • • 10 hours/month
  • @@ -322,7 +328,7 @@

    Standard

    Popular

Maintenance + small improvements

-

From Ksh. 15,000$150/month

  • • 15 hours/month
  • @@ -333,7 +339,7 @@

    Standard

    Priority

    Faster response + monitoring + proactive work

    -

    From Ksh. 25,000$250/month

    • • 20 hours/month
    • @@ -351,7 +357,7 @@

      Priority

      -

      Discovery Fee - Ksh. 5,000

      +

      Discovery Fee - $50

      Credited to the project if you proceed. You receive:

      • @@ -384,7 +390,7 @@

        Discovery Fee - Ksh. 5,000

      -
      +

      Frequently Asked Questions

      @@ -448,7 +454,7 @@

      Frequently Asked Questions

      -
      +

      Ready to Get Started?

      Let's scope your project and find the right plan for you.

      diff --git a/src/templates/products.html b/src/templates/products.html index 70a478e..4d7340a 100644 --- a/src/templates/products.html +++ b/src/templates/products.html @@ -9,7 +9,7 @@ {% block content %} -
      +
      diff --git a/src/templates/projects.html b/src/templates/projects.html index 4611982..31ec7f5 100644 --- a/src/templates/projects.html +++ b/src/templates/projects.html @@ -7,7 +7,7 @@ {% block content %} -
      +
      @@ -21,7 +21,7 @@

      Our +
      @@ -209,7 +209,7 @@

      xPerience Nairobi AI Chat

      -
      +

      Have a Project in Mind?

      Let's discuss how we can bring your idea to life.

      diff --git a/src/templates/services.html b/src/templates/services.html index 0974e29..788e2e3 100644 --- a/src/templates/services.html +++ b/src/templates/services.html @@ -8,7 +8,7 @@ {% block content %} -
      +
      @@ -18,11 +18,19 @@

      Our Services

      From initial consultation to ongoing maintenance, we provide complete software solutions tailored to your needs.

      + +
      + Show prices in: +
      + + +
      +
      -
      +
      @@ -71,7 +79,7 @@

      Basic or Landing Page

      you.

      -

      Ksh. 30,000 - Ksh. 100,000

      +

      $300 – $1,000

      @@ -96,7 +104,7 @@

      Small Business Website

      Analytics tracking, training or light support after launch.

      -

      Ksh. 80,000 - Ksh. 150,000

      +

      $800 – $1,500

      @@ -120,7 +128,7 @@

      E-Commerce Store

      customer emails, performance tuning, PWA, security monitoring & backups.

      -

      Ksh. 130,000 - Ksh. 500,000+

      +

      $1,300 – $5,000+

    @@ -141,7 +149,7 @@

    Corporate / Large Organisation

    robust security, and team collaboration.

-

From Ksh. 350,000+

+

$3,500+

@@ -164,10 +172,10 @@

What we do

enabled us to pursue other fields like the Internet of Things (IoT) and Electric Vehicles Technology.

-

Small discovery fee of Ksh. 5,000 applies, +

Small discovery fee of $50 applies, fully credited if you proceed.

-

Development starts from Ksh. 100,000

+

Development starts from $1,000

Start Your Project @@ -194,7 +202,7 @@

AI Consulting

selection, ensuring your AI initiatives deliver measurable business value while maintaining ethical considerations.

-

Discovery fee of Ksh. 5,000 applies, fully +

Discovery fee of $50 applies, fully credited if you proceed.

@@ -208,10 +216,10 @@

Chatbots

with our AI/ML development services to better comprehend the content of conversations and provide human-like experiences to customers.

-

Discovery fee of Ksh. 5,000 applies, +

Discovery fee of $50 applies, fully credited if you proceed.

-

Development starts at Ksh. 50,000

+

Development starts at $500

@@ -222,10 +230,10 @@

Workflow Automation

build intelligent and adaptive applications.

-

Discovery fee of Ksh. 5,000 applies, +

Discovery fee of $50 applies, fully credited if you proceed.

-

Development starts at Ksh. 100,000

+

Development starts at $1,000

@@ -236,10 +244,10 @@

AI Agent Development

assist you in AI agent consultation, development, integration, training, and optimisation.

-

Discovery fee of Ksh. 5,000 applies, +

Discovery fee of $50 applies, fully credited if you proceed.

-

Development starts at Ksh. 100,000

+

Development starts at $1,000

@@ -261,7 +269,7 @@

Technology Strategy & Consulting

business objectives, and growth plans. Our consultants provide actionable recommendations that drive measurable results.

-

Discovery fee of Ksh. 5,000 applies, fully +

Discovery fee of $50 applies, fully credited if you proceed.

@@ -287,10 +295,10 @@

What we do

controls. We enforce zero-trust policy and ensure only the necessary access is granted through auditable processes.

-

Discovery fee of Ksh. 5,000 applies, fully +

Discovery fee of $50 applies, fully credited if you proceed.

-

Auditing fee starts from Ksh. 100,000+

+

Auditing fee starts from $1,000+

Request a Security @@ -316,10 +324,10 @@

What we do

team going through the processes. This is at least a day and up to five days. We prepare a report for possible automation or optimisations.

-

Discovery fee of Ksh. 5,000 applies, fully +

Discovery fee of $50 applies, fully credited if you proceed.

-

Daily rate of Ksh. 10,000 for process analysis

+

Daily rate of $100 for process analysis

Optimise Your @@ -345,8 +353,7 @@

What we do

Free or donation. If invited, discovery fee - of Ksh. - 5,000 applies, fully credited if you proceed.

+ of $50 applies, fully credited if you proceed.

Services agreements.

Discovery & Engagement

-

Our discovery process begins with a one-time fee of KSh 5,000, which is fully credited +

Our discovery process begins with a one-time fee of $50, which is fully credited toward your project if you decide to proceed. This ensures both parties are committed to productive collaboration.

diff --git a/tests/test_views.py b/tests/test_views.py index 00a3891..e12944c 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,5 +1,9 @@ """Tests for the core app views.""" +import json +from decimal import Decimal +from unittest.mock import AsyncMock, patch + import pytest from django.test import Client from django.urls import reverse @@ -34,3 +38,97 @@ def test_dashboard_login_page_loads(self, client: Client) -> None: """Test that the dashboard login page loads.""" response = client.get(reverse("core:dashboard_login")) assert response.status_code == 200 + + +@pytest.mark.django_db +class TestExchangeRateEndpoint: + """Test the /api/rates/usd-kes/ exchange rate endpoint.""" + + URL = reverse("core:exchange_rate") + + @patch( + "apps.payments.currency_service.get_exchange_rate", + new_callable=AsyncMock, + return_value=Decimal("129.50"), + ) + @pytest.mark.asyncio + async def test_returns_rate(self, mock_rate, async_client) -> None: + """Test that a valid rate is returned.""" + response = await async_client.get(self.URL) + assert response.status_code == 200 + data = json.loads(response.content) + assert data["rate"] == 129.50 + assert data["from"] == "USD" + assert data["to"] == "KES" + mock_rate.assert_awaited_once_with("USD", "KES") + + @patch( + "apps.payments.currency_service.get_exchange_rate", + new_callable=AsyncMock, + side_effect=RuntimeError("API down"), + ) + @pytest.mark.asyncio + async def test_returns_503_on_failure(self, mock_rate, async_client) -> None: + """Test that a 503 is returned when the rate API fails.""" + response = await async_client.get(self.URL) + assert response.status_code == 503 + data = json.loads(response.content) + assert "error" in data + + def test_only_get_allowed(self, client: Client) -> None: + """Test that POST, PUT, DELETE, PATCH are rejected.""" + for method in ["post", "put", "delete", "patch"]: + response = getattr(client, method)(self.URL) + assert response.status_code == 405, f"{method.upper()} should be 405" + + @patch( + "apps.payments.currency_service.get_exchange_rate", + new_callable=AsyncMock, + return_value=Decimal("129.50"), + ) + @pytest.mark.asyncio + async def test_response_is_json(self, mock_rate, async_client) -> None: + """Test that content type is JSON.""" + response = await async_client.get(self.URL) + assert response["Content-Type"] == "application/json" + + @patch( + "apps.payments.currency_service.get_exchange_rate", + new_callable=AsyncMock, + return_value=Decimal("129.50"), + ) + @pytest.mark.asyncio + async def test_no_sensitive_data_leaked(self, mock_rate, async_client) -> None: + """Test that the response contains only the expected fields.""" + response = await async_client.get(self.URL) + data = json.loads(response.content) + allowed_keys = {"rate", "from", "to"} + assert set(data.keys()) == allowed_keys + + @patch( + "apps.payments.currency_service.get_exchange_rate", + new_callable=AsyncMock, + return_value=Decimal("129.50"), + ) + @pytest.mark.asyncio + async def test_no_auth_required(self, mock_rate, async_client) -> None: + """Test that the endpoint is publicly accessible (no login needed).""" + response = await async_client.get(self.URL) + assert response.status_code == 200 + + @patch( + "apps.payments.currency_service.get_exchange_rate", + new_callable=AsyncMock, + return_value=Decimal("129.50"), + ) + @pytest.mark.asyncio + async def test_no_cookies_set(self, mock_rate, async_client) -> None: + """Test that no cookies/auth tokens are set on the response.""" + response = await async_client.get(self.URL) + assert not response.cookies, "Endpoint should not set any cookies" + + def test_does_not_accept_query_params_injection(self, client: Client) -> None: + """Test that arbitrary query params don't cause errors.""" + response = client.get(self.URL + "?currency=EUR&callback=alert(1)") + # Should either return 200 (ignored params) or the normal response + assert response.status_code in (200, 503)