How I built this site, and the business system behind it.
It started as a page about me. Today it is five services on one database that take a client from a first request to a signed contract and a paid invoice, and it is where I test everything before I trust it.
This is the complete tour: plain words first, diagrams you can explore, and the real code behind each part.
It started as a page about me. It became the system I test everything on.
In July 2026 this site was a single page: who I am, what I do, where I have worked. Then I needed a place to take requests from clients, so I added a services store and appointment booking. Then I needed to send proposals, so I built proposals. Then contracts, invoices, online payment, a mailbox and a blog. With every real need the site grew, until it became a small system that runs a complete business.
That changed its purpose. It is now my test bed. Whatever I learn, I apply here first, on a live system that takes real requests and is connected to a real payment gateway. I measure how stable, useful and secure it is, and only then do I decide whether it deserves to move into a company's systems. A mistake here costs me. A mistake in a company system costs employees, customers and money.
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.
Learn
A pattern, a tool, or a better way to do something. It can come from a course, from a failure, or from a problem at work.
Apply here first
Built into this system for real, with real requests and real integrations. Not a demo that only works on my laptop.
Measure
Speed in real visitors' browsers, errors, test results, and whether it actually saved time. A feeling is not a measurement.
Decide
Adopt it, adjust it, or reject it. Each decision is written down with its reason, so I do not try the same thing twice by accident.
Move to companies
Only what survived here goes into a company's systems, and even then it starts small, inside that company's own environment.
A real rejection
I trialled the next major versions of the site's framework and the store generator. Both worked, but the site's core JavaScript grew by 17%, so I kept the current versions and set a date to review them again.
This page is the complete tour: what the system does, how its parts talk to each other, and the code behind the parts I am proudest of. Every section starts in plain words. If you want the engineering, open "Under the hood", or open every technical detail at once from the menu.
The code boxes are real excerpts from the system. They are shortened, and names are changed wherever they would reveal something private. How the private panel is protected, and every key, address and client detail, is left out on purpose.
Architecture
Five services, one database, and everything they talk to.
The system runs on Cloudflare's edge network as five small services: the website, the store, the blog, the API and my operations panel. Each one has a single job and is deployed on its own, and all of them read and write one database, so there is only ever one version of the truth.
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.
Visitors
Anyone reading the site, the store or the blog. No account, and no wall of cookie banners.
Clients
People I work with. They get private links: a proposal to approve, a contract to sign, an invoice to pay, and one page that shows all of it together.
+Under the hood
Each private link is unguessable, and everything behind it is limited to the client it belongs to, never to anything written in the address.
Me
I run everything from one private panel, on a computer or as an app on my phone.
The website
This website: two languages, fifteen projects, the 3D logo, and this page.
+Under the hood
A Next.js 15 static export, prerendered in English and Arabic and served as static assets from a Worker. No server code runs when a page is viewed.
The store
The services store: choose a service, book a time, request a quote, or pay.
+Under the hood
Astro static pages built from the catalog in the database, with a small live layer that asks the API on every visit how each product is sold right now.
The blog
Articles in Arabic and English, published instantly from the panel or automatically from a queue.
+Under the hood
A Hono Worker renders each article from the database on request and caches it at the edge. RSS, the sitemap and the structured data come from the same rows.
The operations panel
My back office: fourteen screens for selling, delivery, content and the system itself.
+Under the hood
Hono on a Worker, server-rendered HTML straight from the database, 124 routes and two runtime dependencies, behind an identity gate. It installs as an app with push notifications.
Search and AI engines
Google, Bing and AI assistants. They get a sitemap in both languages, structured data, an llms.txt file, and an instant notice when a new article goes live.
The API
The engine room: orders, bookings, proposals, contracts, invoices, payments, email and reminders.
+Under the hood
One Worker with three entry points: 22 HTTP routes, a scheduled job every five minutes with 13 isolated steps, and an inbound email handler. Two runtime dependencies.
One database
All five services share it. Nothing keeps its own copy, so a client's state is a fact in one place, not something pieced together from files and messages.
+Under the hood
Cloudflare D1 (SQLite): 30 tables, 55 indexes and 7 triggers, built by 31 ordered migrations. Six triggers make evidence tables append-only, and one freezes signed contracts.
Tap
Online payments through Tap's hosted payment page, including Apple Pay. Card details never touch my system.
+Under the hood
A charge is created when the client presses Pay. Payment messages are verified before anything is written, and one reconciler is shared by the payment message, the return page and the scheduled job.
Email
Every email the system sends, and every email that reaches the domain, which lands in my panel's mailbox.
+Under the hood
Outbound through Resend, written to a notification log before it is sent. Inbound through Cloudflare Email Routing into the API's email handler, parsed, threaded and stored.
Google
My calendar, and what Google says about the site, without leaving the panel.
+Under the hood
Confirmed appointments are mirrored to Google Calendar. Search Console and GA4 figures are pulled daily into the database and shown on their own screen.
LinkedIn
One press in the panel shares a published article to my LinkedIn profile.
GitHub Actions
Every change is tested and deployed from here, the database is backed up every night, and an outside monitor checks the site every 30 minutes. The dashed line is the store's Publish button, which starts a rebuild.
API routes
22
Panel routes
124
Shared modules
20
Database tables
30
Ordered migrations
31
Protective triggers
7
+Under the hood
Twenty shared TypeScript modules hold every rule the API and the panel must agree on: the document seal, short references, Riyadh time, slot collisions, the mail queue, the payment client. Each rule is written once, so the two services cannot drift apart.
A second, schema-only database exists for one purpose: every migration runs there before it touches production.
Use cases
Five kinds of user, and what each one can do.
A system is easiest to understand through the people who use it. Two of the five are not people at all: the clock that runs scheduled work, and the search and AI engines that read the site.
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.
Visitor
Reads, explores, and when ready, books a time or asks for a quote. Nothing asks them to sign up.
Client
Approves a proposal, signs a contract, pays an invoice or one installment of it, and sees everything about our work on one private page.
Search and AI engines
Read the pages in both languages, the structured data and the llms.txt file, to understand who I am and what I built.
Me
I run sales, delivery, email, content and the numbers from one panel, on a computer or on my phone.
The clock
Every five minutes the system wakes up and does the work nobody should have to remember: reminders, retries, reconciliation, billing, publishing. Every night it backs up the database.
Read the site and blog
Static pages in two languages and articles served straight from the database.
Book or ask for a quote
Real free slots only, a form that cannot create a duplicate order, and an answer within one business day.
Pay online
On Tap's hosted page, including Apple Pay, for a direct purchase, a priced proposal or one installment.
Approve and sign
The approval is tied to the exact version the client read, and the signature to the exact text that was sealed.
Follow it all in one link
Orders, documents and invoices with what is paid and what is left. No account and no password.
Understand who I am
One person with a fixed identity that every page points back to.
Run sales, order to invoice
Orders become proposals, proposals become contracts, contracts become invoices with installments.
Answer email in the panel
Every email to the domain arrives as a conversation, and the reply goes from the same screen.
Publish and share articles
Publish by hand or from a queue, then share to LinkedIn with one press. The dashed line is the queue, which publishes on its own.
Watch the numbers
What is waiting on me today, money collected, the funnel from order to contract, and what Google sees.
Remind, retry, reconcile
Reminders before appointments, retries for failed email, and payments matched to orders, every five minutes.
Back up every night
The whole database, with credentials removed, kept for 14 nights.
The commercial path
From a first request to a signed contract and a paid invoice.
This is the heart of the system. Each stage creates the next one, so the state of any client is a fact recorded in one place. Walk through it step by step.
A request comes in
A visitor picks a service in the store and sends a request, or books a time. The order is created, the client gets an acknowledgement, and my phone shows a notification.
+Under the hood
One atomic batch writes the customer, the order, its short reference and the queued emails. The form sends an attempt key, so a retry after a lost answer returns the same order instead of creating a second one.
// One key per booking attempt, created when the form opens and sent with// every retry. A retry after a lost answer gets the same order back, never a// second one. The key is retired only when the attempt is really over.constres = awaitfetch(`${apiBase}/api/orders`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ ...body, attemptKey }),});constj: any = awaitres.json().catch(() => ({}));// Nothing was charged, so the next press is a new attempt, not a retry.if (j.error === 'charge_create_failed' || j.error === 'attempt_closed') attemptKey = '';if (!res.ok) thrownewError(j.error || 'error');// A paid product answers with the payment page, also on a retry.if (j.redirectUrl) { location.assign(j.redirectUrl); return; }msgEl.textContent = en() ? 'Request received. I will contact you within one business day.' : 'تم استلام طلبك. سأتواصل معك خلال يوم عمل واحد.';form.reset();attemptKey = ''; // the next submission is a genuinely new booking
We talk, and I record the agreed amount
I read the brief, contact the client from the order card by WhatsApp, phone or email, agree on the scope, and record the agreed amount. Each status change sends the client a short update, once per status.
+Under the hood
The status update and a conditional email claim run in one batch, keyed by order and status. Moving a status back and forth never sends the same email twice.
The proposal is written and sealed
One press turns the order into a proposal draft with the real facts filled in and blanks for scope, exclusions and timeline. It cannot be published while a blank remains. Publishing seals it.
+Under the hood
The seal is a SHA-256 hash of the exact text and its version. The publish statement applies only if the text is still the text that was hashed, so nobody can approve a version that was never published.
// Publishing seals the proposal with a SHA-256 hash of its exact text and// version. The update only applies if the text is still the text that was// hashed, so nobody can approve a version that was never published.constgap = unfinished(String(p.body_md));if (gap) returnflash(c, "e", gap); // a [[blank]] left in the text stops publishingconsthash = awaitdocumentSeal(p.body_md, p.doc_version);constr = awaitc.env.DB.prepare("UPDATE proposals SET status='published', content_hash=?, published_at=?, updated_at=? WHERE slug=? AND status='draft' AND body_md=?",).bind(hash, nowIso(), nowIso(), slug, p.body_md).run();if (!r.meta.changes) returnflash(c, "e", "تغيّر العرض أثناء النشر، افتح المسودة وأعد المحاولة");awaitaudit(c.env, actor, "proposal.publish", "proposal", slug, `sealed ${hash.slice(0, 12)}`);
The client approves the exact version
The client opens a private page and approves, or asks for changes. The approval is recorded against the version they actually read, and it can never be edited or deleted.
+Under the hood
Approval rows are append-only: database triggers abort any update or delete. If the proposal changes later, older approvals are shown as belonging to a previous version.
It becomes a contract
From the approved proposal, one press opens a contract already filled with its details. I write the clauses and dates, mark it as a monthly retainer if it is one, seal it, and send it for electronic signature.
+Under the hood
Editing a contract that was already sent returns it to draft, because the seal belongs to one exact text. A signed contract is never edited: an amendment is a new version that must be signed again and links back to what it replaces.
The client signs, and the database locks it
The client types their name, ticks the agreement and signs. The system checks the text is still exactly what was sealed, records the signature, and emails a signed copy to both of us. From that moment the database refuses any change to the contract.
+Under the hood
The page posts the hash it displayed; the server compares it and recomputes the seal from the stored text before accepting. An HMAC token is computed over the contract, the hash, the name and the time, so a signature can be verified publicly later, and a daily sweep re-verifies every stored signature.
// Signing is one atomic batch. The first statement claims the contract; the// second records the signature only if this exact claim won. Two signatures// on one contract are impossible, even in the same millisecond.constclaimed = awaitenv.DB.batch([env.DB.prepare(`UPDATE contracts SET status = 'signed', signed_at = ?, updated_at = ? WHERE id = ? AND status = 'sent' AND NOT EXISTS (SELECT 1 FROM contract_signatures s WHERE s.contract_id = contracts.id)`, ).bind(ts, ts, row.id),env.DB.prepare(`INSERT INTO contract_signatures (contract_id, signer_name, content_hash, sig_token, signed_at) SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM contracts WHERE id = ? AND status = 'signed' AND signed_at = ?) AND NOT EXISTS (SELECT 1 FROM contract_signatures WHERE contract_id = ?)`, ).bind(row.id, name, row.content_hash, sigToken, ts, row.id, ts, row.id),]);if (!(claimed[0]?.meta?.changes ?? 0)) {returnjson(c, { error: "already_signed" }, 409);}
-- After a signature exists, the database itself refuses to change the-- contract's text, dates or terms. This holds even if the application above-- it has a bug: the guarantee lives in the data, not in the code.CREATETRIGGERtrg_contracts_freeze_signedBEFOREUPDATEONcontractsWHENEXISTS (SELECT1FROMcontract_signaturessWHEREs.contract_id = OLD.id)AND (NEW.body_mdISNOTOLD.body_mdORNEW.content_hashISNOTOLD.content_hashORNEW.doc_versionISNOTOLD.doc_versionORNEW.starts_onISNOTOLD.starts_onORNEW.ends_onISNOTOLD.ends_onORNEW.is_retainerISNOTOLD.is_retainerORNEW.retainer_amount_minorISNOTOLD.retainer_amount_minor)BEGINSELECTRAISE(ABORT, 'a contract that carries a signature cannot be rewritten'); END;
An invoice with real installments
I create an invoice from the order or the client page and split it into installments, for example 50, 30 and 20 percent. Each installment has a due date and its own payment link, and late ones appear in my morning digest.
+Under the hood
Installments are data, not sentences inside a contract. Amounts are whole halalas, and the last installment takes the remainder, so the parts always add up exactly. Monthly retainers invoice themselves on the first morning run of each month.
// Splitting an invoice into installments without losing a single halala.// Amounts are whole halalas; the last installment takes whatever is left, so// the parts always add up to the total exactly.exportfunctionplanInstallments(totalMinor: number, splitRaw: string): Schedule {constraw = String(splitRaw || "").trim();letpercents = [100];if (raw) {constgiven = raw.split(",").map((x) => Number(x.trim()));percents = given.filter((x) => Number.isFinite(x) && x > 0);// A part that cannot be read is refused, never silently dropped.if (percents.length !== given.length) return { ok: false, reason: "unreadable" };constsum = percents.reduce((a, b) => a + b, 0);if (!percents.length || Math.abs(sum - 100) > 0.01) return { ok: false, reason: "sum" };if (percents.length > 12) return { ok: false, reason: "count" }; }constamounts: number[] = [];letremaining = totalMinor;for (leti = 0; i < percents.length; i++) {constamount = i === percents.length - 1 ? remaining : Math.round(totalMinor * (percents[i] / 100));if (amount <= 0) return { ok: false, reason: "nonpositive" };amounts.push(amount);remaining -= amount; }return { ok: true, amounts, percents };}
Paid, and closed
The client pays online, or I record a bank transfer with one click. The invoice closes itself when nothing is left to pay, the money appears in analytics, and the work moves on to delivery.
+Under the hood
If a card payment and a bank transfer ever arrive for the same debt, both services detect it with the same rule, and I get exactly one alert to refund the duplicate.
01 / 08
Money
Payments built for the bad day.
With customer money, a payment that works is not enough. Every path that touches money was designed for the bad day: the lost answer, the double click, the duplicate message, the client who pays twice.
The charge is created at the press. A Tap payment page expires after 30 minutes, so nothing is created when a link is emailed. The page is created when the client presses Pay, and it is always fresh.
Verify first, then write. Every payment message from Tap is verified before anything is recorded, and the charge is then read again from Tap. Only Tap's own answer changes the state of any money.
One event, once. The same payment message arriving twice is recorded once, and one reconciler is shared by the message, the return page and the scheduled job, so whichever arrives first, the result is the same.
Refunds are written down before they are requested. If the answer is lost, the record still says a refund may have happened, and its retry key belongs to that record, so a retry can never refund twice.
One clear alert for anything unusual. A double payment, a card payment after a bank transfer, test money on a real order, an amount that changed: each produces exactly one alert to me saying what to check.
A selling mode per product. Each product is sold by quote, by booking, or by direct payment, and I can switch it from the panel without rebuilding the store.
Switched on by hand, after a real check. Online payments are switched on only by me, from the panel, and the switch first runs a real check against Tap before it agrees.
The first complete payment went through with Apple Pay in Tap's test environment: the payment was captured, its signature checked out, and both emails arrived.
// Verify first, write second. The duplicate check is built from fields// inside the message, so recording an unverified message would let a forged// one block the real one.if (!(awaitverifyWebhookSignature(tap, payload, received))) {returnjson(c, { error: "bad_signature" }, 401);}constdedupeKey = awaitsha256Hex(`${payload?.id}|${payload?.status}|${payload?.transaction?.created}`);// The same event twice is one event.constins = awaitenv.DB.prepare("INSERT OR IGNORE INTO webhook_events (tap_charge_id, tap_status, dedupe_key, signature_valid, processing_status) VALUES (?, ?, ?, 1, 'verified')",).bind(String(payload?.id || ""), String(payload?.status || ""), dedupeKey).run();if (ins.meta.changes === 0) returnjson(c, { ok: true, duplicate: true });// A signature proves who sent the message, not that its body is true. The// charge is read again from Tap, and only that answer moves any money state.constcharge = awaitretrieveCharge(tap, String(payload.id));awaitreconcile(env, charge);
// Write it down first, then ask for the money back. If the answer is lost,// a PENDING row still says a refund may have happened, and reconciliation// can settle it. The retry key comes from that row, so a retry can never// become a second refund.constclaim = awaitc.env.DB.prepare("INSERT INTO refunds (order_id, idempotency_key, amount_minor, reason, status, created_by) VALUES (?, ?, ?, 'requested_by_customer', 'PENDING', ?)",).bind(row.oid, `rf_claim_${crypto.randomUUID()}`, amount, actor).run();constclaimId = Number(claim.meta?.last_row_id || 0);if (!claimId) returnflash(c, "e", "تعذّر تسجيل المطالبة، ولم يُطلب أي استرداد");constidem = `rf_${row.oid}_${claimId}`;awaitc.env.DB.prepare("UPDATE refunds SET idempotency_key = ? WHERE id = ?").bind(idem, claimId).run();try {rf = awaitcreateRefund(tap, { chargeId: row.cid, amountMinor: amount, reason: "requested_by_customer", idempotencyKey: idem });} catch (e: any) {// "No" and "no answer" are different facts. A refusal frees the amount;// silence keeps the row PENDING, because the money may already have moved.if (e?.refused) {awaitc.env.DB.prepare("UPDATE refunds SET status = 'FAILED' WHERE id = ?").bind(claimId).run(); }}
+Under the hood
Races are tested, not hoped for. A purpose-built laboratory replays two customers acting in the same instant, and a server dying halfway through a request, deterministically. It runs the real handlers one database statement at a time against a fake Tap that follows Tap's published statuses, and it checks 14 rules that must always hold, such as no order paid twice and no money without a record.
Appointments
An owned booking engine: two people, one slot, no double booking.
Instead of a booking tool on someone else's platform, the system has its own engine. I set weekly hours, close specific dates, and hold time for myself. Visitors see only slots that are really free, up to 14 days ahead.
When two people press book on overlapping times in the same instant, both requests are written, then each one looks again, and the older claim keeps the time. The newer one cancels itself and asks the visitor to pick another slot. A paid booking holds its slot while the client pays, and the hold is released on its own if the payment never arrives.
Confirming a request creates its own private video meeting link, emails it to the client, and mirrors the event to my Google Calendar. A reminder goes out the day before. I can also subscribe my phone's calendar to a private feed that works without Google at all.
// Two people press "book" on overlapping times in the same instant. Both// rows are written; then each request re-reads the window, and the older// claim keeps the time. The newer one cancels itself and tells the visitor.exportfunctionslotVerdict(mineId: number,startsAt: string,hours: number,windowRows: SlotRow[],): { overlapping: SlotRow[]; mustYield: boolean } {conststartMs = newDate(startsAt).getTime();constendMs = endOf(startsAt, hours);constoverlapping = windowRows.filter((r) =>r.id !== mineId && collides(startMs, endMs, newDate(r.starts_at).getTime(), endOf(r.starts_at, r.hours)), );// If we cannot identify our own row, we do not yield: destroying a real// booking on an uncertain fact is worse than an overlap I can see and fix.constmustYield = mineId > 0 && overlapping.some((r) => mineId > r.id);return { overlapping, mustYield };}
// Confirming an appointment: one press wins. The status change and the// client's confirmation email are committed together, so a confirmed// booking has always told the client, and a second click changes nothing.conststamp = nowIso();constclaim = c.env.DB.prepare("UPDATE bookings SET status = 'confirmed', meet_link = COALESCE(meet_link, ?), updated_at = ? WHERE id = ? AND status = 'pending_confirm'",).bind(meet, stamp, id);constwon = {sql: "EXISTS (SELECT 1 FROM bookings WHERE id = ? AND status = 'confirmed' AND updated_at = ?)",bind: [id, stamp],};const [claimed] = awaitc.env.DB.batch(mail ? [claim, mailClaim(c.env, mail, won)] : [claim]);if (!claimed?.meta?.changes) returnflash(c, "e", "غير قابل للتأكيد");// The email goes first. The Google Calendar copy is a courtesy and has no// say in whether the client is told.constmailed = mail ? awaitsendClaimed(c.env, mail) : true;
+Under the hood
A unique index on the start time rejects an identical start immediately, and the overlap rule lives in a shared module used by both the API and the panel. A request I have not confirmed within 36 hours is released with an apology email, so a slot is never held by a request nobody answered.
The email to the client goes before the calendar copy. Google is a courtesy and has no say in whether the client is told.
Automation
Every five minutes, thirteen jobs run, and each one is safe to repeat.
A scheduled job runs every five minutes, 288 times a day. It settles payments, releases expired holds, retries failed emails, bills monthly retainers, checks the whole system, expires unanswered requests, sends reminders, prepares my morning digest, re-verifies contract signatures, cleans old logs, and publishes the next article from the blog queue.
Running 288 times a day means every job must be safe to repeat. A reminder is claimed in the database before it is sent, so it is sent once whatever happens. And the first thing the job does on every run is record that it ran. If it ever stops, a warning appears at the top of every screen in my panel, because a job cannot report its own death.
// Every five minutes, 288 times a day. Each step is isolated: one failing// step is logged and the rest still run. The order is deliberate: money is// settled before holds are released, and housekeeping always comes last.asyncscheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext) {conststep = <T>(name: string, work: () => Promise<T>) => work().catch((e) => {console.error(`cron: ${name}:`, String(e?.message || e));returnnull; });constlastTick = awaitstep("heartbeat", () => heartbeat(env));awaitstep("reconcile", () => reconcilePending(env, lastTick));awaitstep("booking holds", () => expireUnpaidBookings(env));awaitstep("mail retry", () => retryWhenDue(env));awaitstep("retainer billing", () => generateRetainerInvoices(env));awaitstep("estate check", () => monitorEstate(env));awaitstep("request expiry", () => expireStaleBookings(env));// Claimed once per row, so 288 runs a day still send one reminder.awaitstep("appointment reminders", () => sendAppointmentReminders(env));awaitstep("digest", () => sendReminderDigest(env));awaitstep("contract reminders", () => remindExpiringContracts(env));// Before housekeeping: a signature that stopped proving anything cannot wait.awaitstep("signature sweep", () => sweepSignatures(env));awaitstep("retention", () => purgeOperationalLogs(env));awaitstep("blog queue", () => publishFromQueue(env));}
+Under the hood
A failed email is retried once an hour: ordinary mail up to three attempts, money mail for a full day. The system check probes the public site and the panel, flags a stuck mail queue or payment messages that used up their retries, and emails me once per new set of problems instead of every five minutes.
An outside monitor on GitHub also checks the store, the blog and the API every 30 minutes, because a Worker cannot reliably watch its neighbours from inside the same zone.
Back office
One panel instead of ten tools.
Most small businesses jump between an inbox, a CRM, a signing tool, an invoicing tool, a calendar and an analytics dashboard. I wanted all of it in one place, organised in the order the work happens: selling, delivery, content, and the system itself. On a computer the screens sit in a side rail; on a phone they open from the bottom, where the thumb reaches.
Schematic of the operations panel1 / 15
Orders
Every request with its brief, status and agreed amount, searchable, with one-tap contact by WhatsApp, phone or email.
Each status change emails the client a short update, once per status
Record a bank transfer in one click; money that arrived twice is flagged
Turn an order into a proposal draft, or create its invoice
Full and partial refunds, written down before any money moves
Proposals
Private offer pages, sealed when published, with each client's approval evidence.
An integrity badge shows whether the published text is still the sealed text
Send a proposal with its payment link in one press
An approved proposal becomes a contract in one press
Contracts
Contracts for electronic signature, from draft, to sealed, to signed.
Monthly retainers that invoice themselves
Signed contracts are frozen; an amendment is a new version
End an agreement with a recorded reason
Signature evidence and a seal check on every contract
Invoices
Real numbered invoices, with installments as data.
Up to 12 installments, each with a due date and its own payment link
A printable invoice
Late installments feed my morning digest
Appointments
A month calendar of every appointment.
Confirm, reschedule or cancel
Weekly hours, closed dates and blocks for my own time
A private calendar feed for my phone
Mail
My mailbox inside the panel: every email to the domain, grouped into real conversations, answered from the same thread.
Arabic and English replies land in one conversation
Stored before anything else happens, so nothing is lost
Clients
One page per client with everything about them, and a board that shows every deal at a glance.
Orders, proposals, contracts, invoices and email in one place, with my notes and where they came from
A seven-stage pipeline, from new request to work in progress
A private portal link for the client
Personal data erasure on request (PDPL) in one action
CSV exports of orders, clients and invoices
Products
The store catalog, edited in the panel.
How each product is sold changes instantly
A Publish button rebuilds the static store from the database
Blog
Writing and publishing articles in both languages.
Publish instantly, or queue for automatic publishing
Import and export articles as CSV
One-press sharing to LinkedIn
Follow-up replies under each article
Analytics
How the business is doing, opening on what is waiting on me today.
Overdue installments, appointments to confirm, unread mail, quiet proposals
Money collected over the last six months
A funnel from order to signed contract
Google
What Google says about the site, on its own screen.
Search Console queries and pages, pulled daily
GA4 visits by page, city, country, device and channel
A property check that says exactly what is wrong, when something is
Notifications
Every automated email with its status and attempts. When a client says nothing arrived, the failed filter shows what happened.
Audit log
An append-only record of what was done, by whom, and to what. The database refuses to edit or delete it.
Settings
Every outside connection in one place: payments, Google, LinkedIn, the calendar feed and the blog's publishing rhythm.
On my phone
The panel installs as an app. New activity sends a push notification, and the app icon shows a number that only goes down when the work is actually done.
+Under the hood
The panel is server-rendered HTML straight from the database, with no front-end framework and no asset pipeline: 9,310 lines of TypeScript across 27 files, 124 routes, and two runtime dependencies. It is written in Arabic first, and one button switches it to English using a dictionary of 561 entries. Every refusal comes back to the screen I was on as a translated message, never a bare error page, and nearly every action writes a line to the append-only audit log.
// The panel is written in Arabic and rendered on the server. One button// walks the live page and swaps each text for its English entry (561 of// them), flips the direction, and re-formats every date in Riyadh time.functionapply(lang){varen = lang === "en", html = document.documentElement;html.lang = lang; html.dir = en ? "ltr" : "rtl";document.querySelectorAll("[data-en]").forEach(function(el){if (el.dataset.ar === undefined) el.dataset.ar = (el.textContent || "");el.textContent = en ? (el.dataset.en || el.dataset.ar) : el.dataset.ar; });texts.forEach(function(p){if (!en) { p[0].nodeValue = p[1]; return; }vart = swap(p[1]); if (t !== null) p[0].nodeValue = t; });varfmt = tsFmt(en);if (fmt) document.querySelectorAll("time[datetime]").forEach(function(el){variso = el.getAttribute("datetime"); if (!iso) return;try { el.textContent = fmt.format(newDate(iso)); } catch (e) {} });document.title = en ? (DICT[docTitle] || docTitle) : docTitle;btn.textContent = en ? "عربي" : "EN";}
// Names and phone numbers arrive from public forms. A cell that starts with// = + - or @ would run as a formula in my own spreadsheet, so it is written// as text. The byte-order mark lets Excel open Arabic correctly.functioncsvCell(v: unknown): string {lets = String(v ?? "");if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;}function csvResponse(name: string, header: string[], rows: unknown[][]): Response { const body = "" + [header, ...rows].map((r) => r.map(csvCell).join(",")).join("\r\n"); return new Response(body, { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${name}"`, }, });}
Email
Email that cannot be lost, and a mailbox that lives in the panel.
Every email the system sends starts life as a row in the database, written in the same atomic step as the fact it announces. If an order is marked paid, its receipt is already queued; if that step fails, neither exists. The email is sent after that, and a failed send is retried every hour.
Email sent to the domain arrives in my panel, parsed, grouped into conversations, and stored before anything else happens. A reply in Arabic and an original in English land in the same thread. My phone gets a notification, and I answer from the same screen.
// An email is first a row, written in the same batch as the fact it// announces. If the order is marked paid, the receipt is already queued;// if the batch fails, neither exists. The send comes after the commit.exportfunctionmailClaim(env: { DB: D1Database }, msg: Mail, when?: { sql: string; bind: unknown[] }): D1PreparedStatement {returnenv.DB.prepare(`INSERT OR IGNORE INTO notifications_log (order_id, template_key, recipient, status, provider, idempotency_key, subject, body_text, attempts, claimed_at) SELECT ?, ?, ?, 'queued', 'resend', ?, ?, ?, 1, ? ${when ? `WHERE${when.sql}` : ""}`, ).bind(msg.orderId, msg.templateKey, msg.to, msg.idem, msg.subject, msg.text, newDate().toISOString(), ...(when?.bind ?? []));}// The send behind a claim this caller won. A failed send is left to the// hourly retry; it is never lost and never sent twice.exportasyncfunctionsendClaimed(env: { DB: D1Database } & EmailEnv, msg: Mail): Promise<boolean> {constr = awaitsendEmail(env, { to: msg.to, subject: msg.subject, text: msg.text });if (r.ok) {awaitenv.DB.prepare("UPDATE notifications_log SET status = 'sent', provider_message_id = ?, claimed_at = NULL WHERE idempotency_key = ?") .bind(r.id || null, msg.idem).run(); } else {awaitenv.DB.prepare("UPDATE notifications_log SET claimed_at = NULL WHERE idempotency_key = ?") .bind(msg.idem).run().catch(() => { /* the lease runs out by itself */ }); }returnr.ok;}
// One conversation is one person plus one subject. Reply prefixes in both// languages are removed, so "Re: X" and "رد: X" land in the thread of "X".exportfunctionnormalizeSubject(subject: string): string {returnString(subject || "") .replace(/^\s*((re|fwd?|رد|اعادة توجيه|إعادة توجيه)\s*:\s*)+/gi, "") .trim() .toLowerCase();}exportasyncfunctionthreadKeyFor(counterpart: string, subject: string): Promise<string> {constdata = `${String(counterpart || "").trim().toLowerCase()}|${normalizeSubject(subject)}`;constd = awaitcrypto.subtle.digest("SHA-256", newTextEncoder().encode(data));return [...newUint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");}
+Under the hood
Outbound mail goes through Resend and carries its own Message-ID, which is stored so that client replies thread correctly. Inbound mail arrives through Cloudflare Email Routing into the API's email handler: the raw message is parsed, HTML-only mail is converted to text with its links kept, and every field is capped before it is stored.
Content
A blog that publishes itself, and shares to LinkedIn in one press.
The blog reads articles straight from the database, so publishing is instant and needs no build. I can publish by hand, or put finished articles in a queue: on a rhythm I set, at a fixed hour in Riyadh time, the system publishes the next one and tells search engines about it through IndexNow.
When an article mentions another one that is not published yet, the mention stays plain text, and turns into a link by itself on the day that article goes live. Under each article I can add short follow-ups, shown as a thread.
LinkedIn is connected once through its official sign-in. After that, one press on a published article posts it to my profile with its title, summary and link, and the panel warns me two weeks before the connection expires.
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.
Write or import
Articles are written in the panel in both languages, or imported as a CSV. Importing the same sheet twice never creates duplicates.
The queue
Nothing is published because a date arrived. A finished article is published when enough time has passed since the last one. An empty queue simply waits.
+Under the hood
The scheduled job checks the hour in Riyadh time and the gap since the last automatic publish, then flips the earliest due draft with a conditional update, so two overlapping runs can never publish the same article twice.
Publish by hand
A toggle in the panel publishes or retracts an article instantly.
Published
The blog serves it from the database within a couple of minutes, in Arabic and English, with structured data for search engines.
+Under the hood
Pages are cached at the edge for two minutes, feeds and the sitemap for five. Only successful pages are cached, so a missing page never outlives the publish that fixes it.
IndexNow
Search engines that support IndexNow are told about the new article and the blog index at once, instead of waiting to crawl.
LinkedIn
Sharing is my decision, so it is one press, not automatic. The button changes once an article is shared, and every share is recorded.
RSS and sitemap
The RSS feed carries the latest 30 articles, and the blog sitemap lists every published one.
// Escape everything first, then allow a short list of Markdown. No raw tag// can survive, a link must be http(s) or a path on this site, and a link to// an article that is not published yet stays plain text until it is.functionesc(s: string): string {returns.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch] asstring));}functioninline(s: string, isPublished: (slug: string) => boolean): string {returns .replace(/`([^`]+)`/g, "<code>$1</code>") .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") .replace(/\*([^*]+)\*/g, "<em>$1</em>") .replace( /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/(?!\/)[^\s)]*)\)/g, (_m, text: string, href: string) => { const self = SELF_ABS.exec(href); const path = self ? self[1] : href; if (!path.startsWith("/")) return `<ahref="${href}"rel="noopener">${text}</a>`; const m = ARTICLE.exec(path); if (m && !m[1].includes(".") && !isPublished(m[1])) return text; return `<ahref="${href}">${text}</a>`; }, );}
// A page built by a Worker is not cached just because it says so: the Worker// has to store it. Only successful pages that declared a lifetime are kept,// never an error, because a cached 404 would outlive the publish that fixes it.exportdefault {asyncfetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {if (req.method !== "GET") returnapp.fetch(req, env, ctx);constcache = caches.default;consthit = awaitcache.match(req);if (hit) returnhit;constres = awaitapp.fetch(req, env, ctx);if (res.status === 200 && (res.headers.get("Cache-Control") || "").includes("s-maxage")) {// A body can be read once: the copy is stored, the original returned.// Failing to cache is a slower page, not a broken one.ctx.waitUntil(cache.put(req, res.clone()).catch((e) => {console.error("blog: cache.put refused", req.url, String(e)); })); }returnres; },};
// One press in the panel shares a published article to LinkedIn: the title,// a short summary and the link, as a public article post.consttitle = p.title_en || p.title_ar;constexcerpt = p.title_en ? (p.excerpt_en || "") : (p.excerpt_ar || "");constcommentary = `${title}${excerpt ? `\n\n${excerpt}` : ""}\n\n${link}`;constres = awaitfetch("https://api.linkedin.com/v2/ugcPosts", {method: "POST",headers: {Authorization: `Bearer ${token}`,"content-type": "application/json","X-Restli-Protocol-Version": "2.0.0", },body: JSON.stringify({author: person,lifecycleState: "PUBLISHED",specificContent: {"com.linkedin.ugc.ShareContent": {shareCommentary: { text: commentary },shareMediaCategory: "ARTICLE",media: [{ status: "READY", originalUrl: link, title: { text: title } }], }, },visibility: { "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" }, }),});
+Under the hood
The Markdown renderer escapes everything first, then allows eight constructs: headings, code blocks, lists, quotes, bold, italic, inline code and links. A link must be http(s) or a path on this site, so no script can ever be smuggled in through an article.
Arabic and English
Two languages, built in from the first line.
English is the main language, and Arabic is not a translation layer on top of it. Every page exists twice, as a real page with its own address. Every sentence lives in one file as an English and Arabic pair, so both versions are built from the same parts and cannot drift apart.
Switching language keeps you on the same page and changes the address, without a reload. Before anything is drawn, a tiny script sets the language and direction. After the build, another script writes Arabic into the bytes of every Arabic page, so search engines and screen readers see Arabic without running any code.
The lesson I keep: test Arabic first. Letter spacing that looks elegant in English pulls Arabic letters apart, so تقنية starts to read like تقذية. That is now a rule in the styles, and a check in the automated visual audit.
// Every page has a real route in both languages: the Arabic one is the// English path under /ar. Switching maps the CURRENT path, so the reader// stays on the same page instead of landing on the home page.functionpathFor(lang: Lang, current: string): string {constbare = current === '/ar' || current === '/ar/' ? '/' : current.startsWith('/ar/') ? current.slice(3) : current;constnormalized = bare.startsWith('/') ? bare : `/${bare}`;if (lang !== 'ar') returnnormalized;returnnormalized === '/' ? '/ar/' : `/ar${normalized}`;}// No reload and no extra history entry: the address simply follows the language.functionsyncPath(lang: Lang) {constcurrent = window.location.pathname;consttarget = pathFor(lang, current);if (target !== current) {window.history.replaceState(null, '', target + window.location.search + window.location.hash); }}
// The first thing inside <body>. The page knows its language and its// direction before anything is drawn, so Arabic never flashes left to right.constlangScript = `(function(){try{ var p = location.pathname; var ar = p === '/ar' || p === '/ar/' || p.indexOf('/ar/') === 0; var d = document.documentElement; d.lang = ar ? 'ar' : 'en'; d.dir = ar ? 'rtl' : 'ltr';}catch(e){}})();`;
// Runs after the build. A static export leaves every page with lang="en",// so this writes Arabic into the bytes of every page under /ar/. Search// engines, screen readers and translators read it without running JavaScript.for (constfileofhtmlFilesUnder(AR_ROOT)) {consthtml = readFileSync(file, "utf8");consttag = html.match(/<html[^>]*>/)?.[0];if (!tag) {console.error(`fix-ar-lang: no <html> tag in ${file}`);process.exit(1); }if (tag.includes('lang="ar"') && tag.includes('dir="rtl"')) continue;constfixed = tag .replace(/\slang="[^"]*"/, ' lang="ar"') .replace(/\sdir="[^"]*"/, ' dir="rtl"');writeFileSync(file, html.replace(tag, fixed));patched++;}// A build that produced no Arabic pages is a broken build, not a quiet one.if (patched === 0 && alreadyCorrect === 0) process.exit(1);
+Under the hood
Layout uses logical properties (start and end instead of left and right), so one stylesheet serves both directions. The language switch runs inside a React transition, so the pressed button paints first and the full re-render follows. Numbers are formatted for each language, and the Arabic font ships three weights with measured fallback faces, preloaded on Arabic pages after the build.
SEO and GEO
Built to be found by search engines, and understood by AI.
SEO is being found by search engines. GEO is being understood by AI assistants, and mentioned in their answers. Both start with the same thing: a page that says clearly who wrote it, what it is about, and how it relates to everything else.
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.
One person, one identifier
Every page describes the same person with the same fixed identifier, so search engines and AI assistants never have to guess whether two pages are about the same Majed.
+Under the hood
A JSON-LD @graph in the root layout declares the Person with an @id on the home page. Every other node, on every page, points back to that @id instead of repeating the person.
ProfilePage
Tells engines that the home page is a profile, and that its main subject is this person.
WebSite
The site itself, with both languages.
Education
Where I studied, linked as a fact rather than a sentence.
knowsAbout
Eighteen topics I work in, from solutions architecture to IT and digital systems management.
Project pages
Each project page declares itself as a creative work with a breadcrumb, and names me as its creator.
This page
This article is a TechArticle whose author is the same Person.
Work samples
Live sites I built, linked as work examples.
LinkedIn posts
Posts about my work are linked with their exact publish times, worked out from the LinkedIn post ids themselves.
Every word is in the HTML before any script runs.
Every page declares its own official address and its version in the other language, with hreflang.
One official address for the whole site: duplicate public copies were switched off or redirected to the main domain.
robots.txt welcomes 27 AI crawlers by name, and llms.txt gives AI engines a plain summary of who I am and where everything lives.
A sitemap lists every page in both languages, and new articles are announced through IndexNow.
Search Console and GA4 numbers come into the panel daily, so I watch what is found and not only what is published.
To be fair about it: this makes the site readable and understandable. Being mentioned in answers also takes real content and references from other places, over time.
// Both language routes of a case page call this one builder, so the English// and Arabic pages cannot disagree. `creator` points at the same Person id as// the home page, which makes every project a link in one knowledge graph.constgraph = {'@context': 'https://schema.org','@graph': [ {'@type': 'CreativeWork','@id': `${url}#project`,name: project.name[lang],description: project.description[lang],inLanguage: lang,url,sameAs: project.liveUrl,creator: { '@id': `${SITE}/#person` },keywords: project.stack.join(', '), }, {'@type': 'BreadcrumbList',itemListElement: [ { '@type': 'ListItem', position: 1, name: OWNER[lang], item: home }, { '@type': 'ListItem', position: 2, name: project.name[lang], item: url }, ], }, ],};
// How the publish time of a LinkedIn post was worked out for the structured// data: the post id carries it. The top 41 bits are milliseconds since 1970.constpublishedAt = (activityId: string) =>newDate(Number(BigInt(activityId) >> 22n)).toISOString();publishedAt('7431610991085297665');// '2026-02-23T08:08:52.930Z'
# Majed Alandajani> Solutions Architect and AI Automation Engineer in Jeddah, Saudi Arabia.> He builds production systems that turn manual operations into> autonomous workflows.## Pages## Selected work## Profiles## Honors and Community Endorsements## Live Production Platforms Architected & Built by Majed
Performance
A 3D logo, and still fast on a phone.
The home page opens with a 3D scene: 47 characters of code drift, gather, and are absorbed by metal strokes that write my monogram as you scroll. A scene like that can easily make a site slow, so the rule was simple: the text loads first, and the 3D never stands in its way.
The mobile speed score was 44. The obvious suspect was the 3D scene, but measuring said otherwise: a scroll library was nudging a paragraph after it appeared, and the browser counted the main content as appearing again, later than it really did. I found it by blocking one file at a time and measuring again. The pinned scroll became plain CSS, the 3D moved to a background thread, and analytics now waits for the visitor. The score rose to about 85, and screenshots at four scroll positions matched the previous version.
The 3D scene waits for the intro and for the browser to be idle, then draws inside a Web Worker. If that fails it draws on the main thread, and without WebGL a flat drawing of the mark appears instead.
It draws only when something changes, slows itself for ambient motion, and stops completely when it is off screen.
Each home section hydrates on its own, and sections below the fold skip layout until the browser is idle.
Analytics loads on the first interaction or after seven seconds, and sends only the page path.
Files with hashed names are cached for a year, and anyone who prefers reduced motion gets the final state with quick fades.
Real visitors are what matter most. When I measured in September, 75% of real visits showed their main content within one second.
// The 3D scene draws inside a Web Worker, so three.js never blocks the page.// Three outcomes race (ready, error, silence) and a single latch settles the// race once: the worker keeps the canvas, or the page falls back to drawing// on the main thread. The visitor sees the logo either way.constworker = newWorker(newURL('./scene.worker.tsx', import.meta.url));letsettled = false;constfallBack = () => {if (settled) return;settled = true;setUseWorker(false);};consttimer = window.setTimeout(fallBack, READY_TIMEOUT_MS);worker.onerror = fallBack;worker.onmessage = (e: MessageEvent<{ type: string }>) => {if (e.data.type === 'error') fallBack();if (e.data.type === 'ready' && !settled) {settled = true;window.clearTimeout(timer); }};constrect = canvas.getBoundingClientRect();constoffscreen = canvas.transferControlToOffscreen();constdpr = Math.min(Math.max(1, window.devicePixelRatio || 1), 1.5);worker.postMessage( { type: 'init', canvas: offscreen, width: rect.width, height: rect.height, dpr }, [offscreen],);
// The scene draws only when asked. About 30 frames a second while the// pointer plays with it, about 18 for the slow ambient motion, nothing at// all when it is still, and nothing while it is off screen.constloop = (time: number) => {if (pausedRef.current) {raf = requestAnimationFrame(loop);return; }constp = progressRef.current;const { x: mx, y: my } = mouseRef.current;if (Math.abs(mx - lastMx) + Math.abs(my - lastMy) > 0.0005) {lastMx = mx;lastMy = my;lastMoveAt = time; }constpointerActive = time - lastMoveAt < 900;constanimActive = !reduced && (p < 0.6 || p >= 0.92);constinterval = pointerActive ? 33 : 55;consttimed = (animActive || pointerActive) && time - lastTick > interval;if (p !== last || timed) {invalidate();last = p;if (timed) lastTick = time; }raf = requestAnimationFrame(loop);};
// The hold while you scroll is plain CSS (position: sticky). This effect only// turns the scroll position into a number from 0 to 1. Sizes are measured by a// ResizeObserver, so the scroll handler never forces the browser to lay out.useEffect(() => {if (reduced) {progressRef.current = 1;return; }constpin = pinRef.current;constsection = sectionRef.current;if (!pin || !section) return;lettop = 0;letdistance = 1;constupdate = () => {progressRef.current = Math.min(1, Math.max(0, (window.scrollY - top) / distance)); };constmeasure = () => {top = pin.getBoundingClientRect().top + window.scrollY;distance = Math.max(1, pin.offsetHeight - section.offsetHeight);update(); };constro = newResizeObserver(measure);ro.observe(pin);ro.observe(section);window.addEventListener('scroll', update, { passive: true });return () => {ro.disconnect();window.removeEventListener('scroll', update); };}, [reduced]);
// Sections below the fold skip layout on the first load (content-visibility).// After six seconds they are released one per idle moment, and the first// click or key press releases all of them, so a jump link always lands right.constRELEASE_AFTER_MS = 6000;exportfunctionDeferredLayoutRelease() {useEffect(() => {letidle = 0;constnext = () => document.querySelector('.cv-section');constreleaseAll = () => {document.querySelectorAll('.cv-section').forEach((el) => el.classList.remove('cv-section')); };constschedule = (fn: () => void) =>typeofwindow.requestIdleCallback === 'function' ? window.requestIdleCallback(fn, { timeout: 1000 }) : window.setTimeout(fn, 50);conststep = () => {next()?.classList.remove('cv-section');if (next()) idle = schedule(step); };consttimer = window.setTimeout(() => { idle = schedule(step); }, RELEASE_AFTER_MS);constevents = ['pointerdown', 'keydown'] asconst;events.forEach((e) => window.addEventListener(e, releaseAll, { once: true, passive: true }));return () => {window.clearTimeout(timer);if (typeofwindow.cancelIdleCallback === 'function') window.cancelIdleCallback(idle);events.forEach((e) => window.removeEventListener(e, releaseAll)); }; }, []);returnnull;}
// The analytics queue exists from the first moment, so no event is lost.// The library itself waits for the visitor's first interaction, or seven// seconds, and only the page path is sent, never the query string.gtag('config', GA_ID, { page_location: location.origin + location.pathname });(function () {vardone = false, o = { passive: true }, t;varev = ['pointerdown', 'keydown', 'touchstart', 'scroll'];functionload() {if (done) return;done = true;clearTimeout(t);ev.forEach(function (e) { removeEventListener(e, load, o); });vars = document.createElement('script');s.async = true;s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID;document.head.appendChild(s); }ev.forEach(function (e) { addEventListener(e, load, o); });t = setTimeout(load, 7000);})();
Storefront
A static store that still knows what is true right now.
The store is built as static pages from the catalog in the database. When I change the catalog in the panel and press Publish, a GitHub Actions job rebuilds and deploys it. If the API cannot be reached during the build, the catalog kept in code is used, so a publish never fails because of a network hiccup.
On top of the static pages sits a small live layer: on every visit the store asks the API how each product is sold right now. That is how I can switch a product from a quote request to direct payment without any rebuild.
On a computer, the booking form grows out of the button you pressed; on a phone it becomes a sheet from the bottom. Each attempt carries its own key, so a retry after a lost answer never creates a second order, and the human check loads only when the form opens.
// At build time the catalog comes from the database through the API. If the// API cannot be reached, the catalog kept in code is used, so a network// hiccup can never fail a publish.letOFFS: Off[] = OFFERINGS.map(toCard);try {constr = awaitfetch(`${SITE}/api/products`, { headers: { accept: 'application/json' } });if (r.ok) {constj: any = awaitr.json();if (Array.isArray(j?.offerings) && j.offerings.length > 0) OFFS = j.offerings; }} catch { /* offline build: the fallback stays */ }
// The store is built once, as static pages. Then, on every visit, it asks the// API how each product is sold right now: request a quote, book, or pay.// Changing how a product is sold needs no rebuild.constlive = newMap<string, { paymentMode?: string; priceMinor?: number | null }>();letpaymentsOn = false;fetch(`${apiBase}/api/products`) .then((r) => (r.ok ? r.json() : null)) .then((j: any) => {if (!Array.isArray(j?.offerings)) return;paymentsOn = !!j.paymentsOn;for (constoofj.offerings) live.set(o.slug, o);document.querySelectorAll<HTMLElement>('[data-book]').forEach((btn) => {consto = live.get(btn.dataset.book || '');if (!paymentsOn || o?.paymentMode !== 'direct' || !o.priceMinor) return;relabel(btn, 'ادفع', 'Pay'); }); }) .catch(() => { /* the built page stands as it is */ });
// One key per booking attempt, created when the form opens and sent with// every retry. A retry after a lost answer gets the same order back, never a// second one. The key is retired only when the attempt is really over.constres = awaitfetch(`${apiBase}/api/orders`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ ...body, attemptKey }),});constj: any = awaitres.json().catch(() => ({}));// Nothing was charged, so the next press is a new attempt, not a retry.if (j.error === 'charge_create_failed' || j.error === 'attempt_closed') attemptKey = '';if (!res.ok) thrownewError(j.error || 'error');// A paid product answers with the payment page, also on a retry.if (j.redirectUrl) { location.assign(j.redirectUrl); return; }msgEl.textContent = en() ? 'Request received. I will contact you within one business day.' : 'تم استلام طلبك. سأتواصل معك خلال يوم عمل واحد.';form.reset();attemptKey = ''; // the next submission is a genuinely new booking
+Under the hood
The store is Astro with two runtime dependencies, self-hosted fonts, one page per service with Service structured data, and plain-language privacy and terms pages. The language you choose travels with you across the home page, the store, the blog and the booking page. The category carousel measures its geometry rarely and moves its cards with CSS custom properties only.
Delivery and quality
From a commit to production, with nothing deployed by hand.
Every change takes the same road. A push runs the tests, works out which of the five services actually changed, refuses to deploy if the database has a migration waiting, deploys only what changed, checks each live address, and then opens the public site in a real browser, in both languages, on a phone and a desktop.
Select any box to see what it does and what it connects to. Swipe sideways to see the whole map.
Push
A change is pushed to the main branch. Documentation-only changes do not deploy anything.
Plan
Works out which of the five services changed. Shared code counts as both the API and the panel, and test-only changes count as nothing.
Migration gate
If production has a database migration waiting, everything stops. Code never reaches a database that is not ready for it.
Deploy
Each changed service runs its type checks and tests again inside its own job, then deploys. The website job also writes Arabic into the Arabic pages and preloads the Arabic font.
Verify live
Each live address must answer as expected. Six tries, waiting longer each time, with a fresh query so no cache can answer for the new version.
Browser audit
A real browser opens the public pages, the store, the blog and the newest article in both languages, on a phone and a desktop, and fails the run if anything is broken.
Tests
1,212 automated tests run on every push, in parallel, and again inside each deploy job. The dashed line means a failing test stops the deploy.
Nightly backup
Every night the database is exported with credentials removed, scanned, and kept for 14 nights. The platform also keeps a 30-day point-in-time history.
Uptime watch
A machine outside Cloudflare checks the store, the blog and the API every 30 minutes and emails me if one stays down.
Automated tests
1,212
Invariants in the race lab
14
Panel screens audited
23
Deploy jobs
7
Nightly backups kept
14
Days of restore history
30
Database changes are rehearsed on a separate staging database first, with a backup of production taken before anything runs. Restores are rehearsed too: a backup is restored into a throwaway database and its row counts compared before production is ever touched.
# A push deploys only what it changed. Shared code is compiled into both the# API and the panel, so a change there redeploys both. Test-only changes do# not change what a Worker serves, so they deploy nothing.- name: Planrun: |has() { grep -qE"$1" <<<"$CHANGED"; }WORKER_FILES="$(grep -vE '^store-system/[^/]+/test/' <<<"$CHANGED" || true)"hasw() { grep -qE"$1" <<<"$WORKER_FILES"; }SHARED=0ifhasw'^store-system/shared/'; thenSHARED=1; fiifhasw'^store-system/api/' || [ "$SHARED" = 1 ]; thenonapi; fiifhasw'^store-system/admin/' || [ "$SHARED" = 1 ]; thenonadmin; fiifhasw'^store-system/blog/'; thenonblog; fiSITE_FILES="$(grep -vE '^scripts/ci/' <<<"$CHANGED" || true)"ifgrep -qE'^(src/|public/|assets/|scripts/|next\.config|wrangler\.worker\.jsonc|package\.json)' <<<"$SITE_FILES"; thenonportfoliofi
# After every deploy the live address must answer as expected. Six tries,# waiting 5, 10, 20, 40 and 80 seconds, with a fresh query each time so no# cache can answer for the new version.ATTEMPTS=6WAIT=5foriin$(seq1$ATTEMPTS); dosep="?"; case"$URL"in *\?*) sep="&";; esacgot="$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 "${URL}${sep}ci=$(date +%s)-$i" || echo 000)"case",$WANT,"in *",$got,"*) matched=1;; *) matched=0;; esacif [ "$matched" = 1 ]; thenecho"OK $URL answered $got (attempt $i)"exit0fiecho".. $URL answered $got, wanted $WANT (attempt $i of $ATTEMPTS)" [ "$i" -lt"$ATTEMPTS" ] && sleep"$WAIT" && WAIT=$((WAIT * 2))doneecho"::error::$URL answered $got after $ATTEMPTS attempts, expected $WANT."exit1
// Tests run against a real SQLite database built from the same 31// migrations as production. A batch here is a real transaction, exactly like// D1: statement N sees statement N-1, and any failure rolls back everything.asyncbatch(stmts) {if (inBatch) thrownewError("D1_ERROR: batch() called while a batch is already open");inBatch = true;db.exec("BEGIN");try {constout = stmts.map((s) => s._runSync());db.exec("COMMIT");returnout; } catch (e) {db.exec("ROLLBACK");throwe; } finally {inBatch = false; }},
+Under the hood
Tests use node:test with no framework. They run the shipped Worker through real requests against a database built from the real migrations. A concurrency laboratory replays races and crashes deterministically, a runtime proof runs inside workerd itself, and a separate visual audit opens 23 panel screens at two widths in both languages, failing on a clipped label or on letter spacing applied to Arabic.
Security
Security and privacy, as far as can be said in public.
Some things are left out of this page on purpose: how the private panel is protected, where private pages live, and every key and every limit. What can be shared is the shape of it.
One address, encrypted, in careful steps. The site answers on HTTPS only, with HSTS raised in steps: five minutes, then a week, then a full year once everything was proven. A browser that has stored HSTS will not open the site unencrypted, and the server cannot take that back, so it was not rushed.
No duplicate copies. Two complete public copies of the site were found on other addresses, one of them unencrypted. One was switched off and the other now redirects to the main domain.
Headers and a policy that was watched before it was enforced. Baseline security headers on all five services, and a Content Security Policy that ran in report-only mode for about a week before being enforced, so it blocks what it should without breaking the site.
Evidence the application cannot rewrite. Payment messages are verified before they are recorded, signed contracts are frozen by the database, and approvals, signatures and the audit log are append-only.
A private panel behind an identity gate. The operations panel is not reachable without passing an identity gate, and the public forms are protected by a human check that loads only when needed.
No secret in the code. No secret lives in the code repository. Nightly backups have credentials removed and are scanned before they are saved.
Privacy as an action. Erasing a person under the Saudi data protection law (PDPL) is one action. They are removed everywhere at once, while the documents the law requires me to keep survive with their name replaced.
// Erasing a person (PDPL) without destroying the documents the law says I// must keep. Every identifier is replaced in place, longest first, so the// scope and the amounts survive while the person does not.constMARK = "[محذوف بموجب طلب PDPL]";constvalues = [...newSet(raw.map((v) => String(v || "").trim()).filter((v) => v.length > 2))] .sort((x, y) => y.length - x.length);constparams = values.flatMap((v) => [v, MARK]);// replace(replace(col, ?, ?), ?, ?) ... one layer per value that exists,// because SQLite's replace() returns NULL if any argument is NULL.constscrub = (col: string) => values.reduce((e) => `replace(${e}, ?, ?)`, col);for (constpofproposals) {stmts.push(env.DB.prepare(`UPDATE proposals SET client_email = NULL, title_ar = ${scrub("title_ar")}, title_en = ${scrub("title_en")}, body_md = ${scrub("body_md")} WHERE id = ?`, ).bind(...params, ...params, ...params, p.id));}// All statements run as one batch: the person is erased everywhere, or nowhere.
Judgement
What I decided, what I rejected, and what this cannot prove.
Rejected: a major upgrade. The next major versions of the website framework and the store generator both worked. But the site's core JavaScript grew by 17%, and the store lost its glass effect in Chrome without workarounds. I kept the current versions and set a date to review them.
Replaced: a scroll library. The library that pinned the hero distorted how the page's load was measured. Plain CSS does the same job, with the same look.
Kept on purpose: the 3D library version. Newer releases change how the logo is drawn, and the logo does not change without a reason.
Changed after review: how fixes are treated. After building the payment modes every test passed, yet a code review found 33 real issues, and a second review found 14 more, two of them introduced by fixes from the first. Since then, any fix that touches money or customer data is treated as new code that needs its own review.
What this test bed cannot prove
Scale. A personal site receives a limited number of visitors. What holds here may not hold with thousands of users, and that needs separate load testing.
Teams. I am the only user of the panel. Permissions, approvals between departments, and handing over accounts when someone leaves are not tested here.
Compliance. The site trains me in the controls, but it does not prove that a company complies with data protection law or with cybersecurity controls.
Existing systems. I built this from scratch. Companies have older systems, vendors and ways of working, and moving anything into them needs its own plan.
So nothing here is ready for companies just because it works here. Working here earns it the next trial, inside the company, on a small scale first.
Technologies
Every technology in the system, and what it does.
Next.js 15 and React 19: the website, exported as static pages in two languages.
TypeScript: every service, from end to end.
Tailwind CSS: the design tokens and layout of the website.
three.js, React Three Fiber and drei: the 3D logo and the ARA Brain story scene, drawn in a Web Worker where possible.
Lenis: smooth scrolling on the home page, skipped for anyone who prefers reduced motion.
Astro: the services store, built as static pages from the database.
Hono: the API, the blog and the operations panel.
Cloudflare Workers: hosting for all five services, plus the scheduled job and inbound email.
Cloudflare D1 (SQLite): one database for everything, with triggers that protect evidence.
Cloudflare Email Routing and Resend: email in and email out.
postal-mime: reading inbound email.
Tap Payments: online payments through a hosted page, including Apple Pay.
Web Crypto (SHA-256 and HMAC): document seals, signature tokens and payment message verification.
Google Calendar, Search Console and GA4: calendar mirroring, and search and visitor numbers inside the panel.
LinkedIn API: one-press article sharing.
IndexNow: instant notice to search engines.
Cloudflare Turnstile: the human check on public forms.
Web Push and a service worker: notifications and the app badge on my phone.
GitHub Actions: tests, deploys, store rebuilds, nightly backups and an outside uptime monitor.
node:test and node:sqlite: 1,212 automated tests against a real database built from the migrations.
Playwright: visual audits of the panel and of the live site in a real browser.
Why I wrote all of this down
I built this system to run my own work, and to have a place where I can try an idea for real before I recommend it to anyone. Writing it down is part of the same discipline: if I cannot explain a part simply, I probably do not understand it well enough yet.
If you are building something similar, or you want a system like this for your company, I am happy to talk.