diff --git a/CHANGELOG.md b/CHANGELOG.md index 7725ef4..fc88439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the NodeByte Hosting website will be documented in this f The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.5.4] - 2026-05-30 + +### Added +- **Google Ads Integration** — Google tag (`AW-16740819749`) installed via `next/script` with `strategy="afterInteractive"` in the root layout; fires after hydration without blocking render + - `packages/core/lib/gtag.ts` — central utility exporting `GOOGLE_ADS_ID`, `CONVERSION_IDS` map, and ready-to-call `conversions.*()` helpers for all 10 goals (begin checkout, subscribe, purchase, submit lead form, page view, sign-up, get directions, request quote, outbound click, contact) + - `packages/ui/components/google-ads-pageview.tsx` — client component that re-fires the page-view conversion on every App Router route change via `usePathname` + +### Changed +- **`About`, `Features`, `Services` converted to Server Components** — `"use client"` directive removed from all three home page sections; none use client-only APIs, state, or effects, so they now render as HTML on the server, reducing the client JS bundle and improving TTI +- **Currency rates — localStorage TTL cache** — `CurrencyProvider` now checks `localStorage` for a cached rates response before fetching `/api/currency/rates`; cache TTL is 1 hour (matching the API's `s-maxage`), so repeat page loads within the hour skip the network request entirely + +### Fixed +- **Canvas globe — per-frame string allocation** — the dot-grid draw loop in `hero-graphic.tsx` was creating a new `` `rgba(150,175,215,${a})` `` string for every dot on every 60 fps frame (~500+ allocations/frame); replaced with a precomputed 32-entry `DOT_ALPHA_TABLE` lookup built once at module load time + +--- + ## [3.5.3] - 2026-04-17 ### Added diff --git a/app/layout.tsx b/app/layout.tsx index a425730..9c353b4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,8 +2,11 @@ import type React from "react" import type { Metadata } from "next" import { cookies } from "next/headers" import { Geist, Geist_Mono } from "next/font/google" +import Script from "next/script" import { Analytics } from "@vercel/analytics/next" import { NextIntlClientProvider } from "next-intl" +import { GoogleAdsPageView } from "@/packages/ui/components/google-ads-pageview" +import { GOOGLE_ADS_ID } from "@/packages/core/lib/gtag" import { getMessages, getLocale } from "next-intl/server" import "./globals.css" import { Toaster } from "@/packages/ui/components/ui/toaster" @@ -154,6 +157,20 @@ export default async function RootLayout({ + + {/* Google Ads tag */} + ) diff --git a/package.json b/package.json index 4fc5cdc..d645779 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@nodebyte/hosting-site", "description": "The official website for NodeByte Hosting.", "license": "AGPL-3.0-only", - "version": "3.3.0", + "version": "3.5.4", "scripts": { "build": "next build", "dev": "next dev", diff --git a/packages/core/hooks/use-currency.tsx b/packages/core/hooks/use-currency.tsx index ed67401..96ef395 100644 --- a/packages/core/hooks/use-currency.tsx +++ b/packages/core/hooks/use-currency.tsx @@ -44,11 +44,38 @@ export function CurrencyProvider({ children }: { children: ReactNode }) { setCurrencyState(getDefaultCurrency()) } - // Fetch live exchange rates — falls back to static if unavailable + // Fetch live exchange rates — falls back to cached or static if unavailable + const RATES_CACHE_KEY = "nb_currency_rates" + const RATES_CACHE_TTL = 3600_000 // 1 hour in ms + + try { + const cached = localStorage.getItem(RATES_CACHE_KEY) + if (cached) { + const { rates, ts } = JSON.parse(cached) as { rates: Record; ts: number } + if (Date.now() - ts < RATES_CACHE_TTL) { + setLiveRates((prev) => { + const updated = { ...prev } + for (const [code, rate] of Object.entries(rates)) { + if (code in updated) updated[code as CurrencyCode] = rate + } + return updated + }) + return // skip network fetch — cache is fresh + } + } + } catch { + // corrupted cache entry — ignore and fall through to fetch + } + fetch("/api/currency/rates") .then((r) => r.json()) .then((data: { rates?: Record }) => { if (data.rates) { + try { + localStorage.setItem(RATES_CACHE_KEY, JSON.stringify({ rates: data.rates, ts: Date.now() })) + } catch { + // localStorage quota exceeded — ignore + } setLiveRates((prev) => { const updated = { ...prev } for (const [code, rate] of Object.entries(data.rates!)) { diff --git a/packages/core/lib/gtag.ts b/packages/core/lib/gtag.ts new file mode 100644 index 0000000..9c1cfc8 --- /dev/null +++ b/packages/core/lib/gtag.ts @@ -0,0 +1,46 @@ +export const GOOGLE_ADS_ID = "AW-16740819749" + +/** Send-to labels for each conversion goal */ +export const CONVERSION_IDS = { + beginCheckout: `${GOOGLE_ADS_ID}/qUthCNieibYcEKXG0q4-`, + subscribe: `${GOOGLE_ADS_ID}/2l-5CNueibYcEKXG0q4-`, + purchase: `${GOOGLE_ADS_ID}/eDjjCN6eibYcEKXG0q4-`, + submitLeadForm: `${GOOGLE_ADS_ID}/CY9iCOGeibYcEKXG0q4-`, + pageView: `${GOOGLE_ADS_ID}/li9bCOSeibYcEKXG0q4-`, + signUp: `${GOOGLE_ADS_ID}/ElPhCOeeibYcEKXG0q4-`, + getDirections: `${GOOGLE_ADS_ID}/E4xrCOqeibYcEKXG0q4-`, + requestQuote: `${GOOGLE_ADS_ID}/WWVMCO2eibYcEKXG0q4-`, + outboundClick: `${GOOGLE_ADS_ID}/Y6xZCPCeibYcEKXG0q4-`, + contact: `${GOOGLE_ADS_ID}/Z1z3CJGcoLYcEKXG0q4-`, +} as const + +declare global { + interface Window { + gtag: (...args: unknown[]) => void + dataLayer: unknown[] + } +} + +function fireConversion( + sendTo: string, + extra?: Record, +): void { + if (typeof window === "undefined" || typeof window.gtag !== "function") return + window.gtag("event", "conversion", { send_to: sendTo, ...extra }) +} + +/** Ready-to-call helpers for each conversion goal */ +export const conversions = { + beginCheckout: () => fireConversion(CONVERSION_IDS.beginCheckout), + subscribe: () => fireConversion(CONVERSION_IDS.subscribe), + /** @param transactionId Optional order / invoice ID */ + purchase: (transactionId = "") => + fireConversion(CONVERSION_IDS.purchase, { transaction_id: transactionId }), + submitLeadForm: () => fireConversion(CONVERSION_IDS.submitLeadForm), + pageView: () => fireConversion(CONVERSION_IDS.pageView), + signUp: () => fireConversion(CONVERSION_IDS.signUp), + getDirections: () => fireConversion(CONVERSION_IDS.getDirections), + requestQuote: () => fireConversion(CONVERSION_IDS.requestQuote), + outboundClick: () => fireConversion(CONVERSION_IDS.outboundClick), + contact: () => fireConversion(CONVERSION_IDS.contact), +} diff --git a/packages/ui/components/Layouts/Home/about.tsx b/packages/ui/components/Layouts/Home/about.tsx index f2312ef..5c59deb 100644 --- a/packages/ui/components/Layouts/Home/about.tsx +++ b/packages/ui/components/Layouts/Home/about.tsx @@ -1,5 +1,3 @@ -"use client" - import type React from "react" import { Card } from "@/packages/ui/components/ui/card" import { Heart, Code, Gamepad2, Server, Sparkles, ArrowRight, Globe, Shield, Zap } from "lucide-react" diff --git a/packages/ui/components/Layouts/Home/features.tsx b/packages/ui/components/Layouts/Home/features.tsx index 3831932..f4ba04b 100644 --- a/packages/ui/components/Layouts/Home/features.tsx +++ b/packages/ui/components/Layouts/Home/features.tsx @@ -1,5 +1,3 @@ -"use client" - import React from "react" import { Shield, Zap, Globe, Lock, Eye, Server, ArrowRight, CheckCircle2 } from "lucide-react" import { Card } from "@/packages/ui/components/ui/card" diff --git a/packages/ui/components/Layouts/Home/hero-graphic.tsx b/packages/ui/components/Layouts/Home/hero-graphic.tsx index df8b47b..4712f0c 100644 --- a/packages/ui/components/Layouts/Home/hero-graphic.tsx +++ b/packages/ui/components/Layouts/Home/hero-graphic.tsx @@ -16,6 +16,14 @@ const CY = SIZE / 2 const cT = Math.cos(TILT) const sT = Math.sin(TILT) +// Precomputed alpha lookup — avoids template-string allocation on every dot per frame. +// alpha = 0.025 + 0.135 * (z / R), z ∈ (0, R] → 32 discrete levels. +const DOT_ALPHA_LEVELS = 32 +const DOT_ALPHA_TABLE: string[] = Array.from({ length: DOT_ALPHA_LEVELS }, (_, i) => { + const a = 0.025 + 0.135 * ((i + 1) / DOT_ALPHA_LEVELS) + return `rgba(150,175,215,${a.toFixed(3)})` +}) + // ─── Locations ──────────────────────────────────────────────────────────────── type Region = "eu" | "am" | "ap" @@ -159,8 +167,8 @@ export default function HeroGraphic() { for (let lon = 0; lon < 360; lon += step) { const p = proj(lat, lon, rot) if (p.z <= 0) continue - const a = 0.025 + 0.135 * (p.z / R) - ctx.fillStyle = `rgba(150,175,215,${a})` + const idx = Math.min(DOT_ALPHA_LEVELS - 1, Math.floor((p.z / R) * DOT_ALPHA_LEVELS)) + ctx.fillStyle = DOT_ALPHA_TABLE[idx] ctx.fillRect(p.x - DOT_PX, p.y - DOT_PX, DOT_PX * 2, DOT_PX * 2) } } diff --git a/packages/ui/components/Layouts/Home/services.tsx b/packages/ui/components/Layouts/Home/services.tsx index ae51014..be5e4d7 100644 --- a/packages/ui/components/Layouts/Home/services.tsx +++ b/packages/ui/components/Layouts/Home/services.tsx @@ -1,5 +1,3 @@ -"use client" - import { Button } from "@/packages/ui/components/ui/button" import { Card } from "@/packages/ui/components/ui/card" import { Layers, ArrowRight, Check } from "lucide-react" diff --git a/packages/ui/components/google-ads-pageview.tsx b/packages/ui/components/google-ads-pageview.tsx new file mode 100644 index 0000000..fdd976d --- /dev/null +++ b/packages/ui/components/google-ads-pageview.tsx @@ -0,0 +1,19 @@ +"use client" + +import { useEffect } from "react" +import { usePathname } from "next/navigation" +import { conversions } from "@/packages/core/lib/gtag" + +/** + * Fires the Google Ads page-view conversion on every client-side route change. + * Must be rendered inside the root layout (client boundary). + */ +export function GoogleAdsPageView() { + const pathname = usePathname() + + useEffect(() => { + conversions.pageView() + }, [pathname]) + + return null +}