mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
Replaces the $250/week retainer with a $400/hour rate billed against actual hours, invoiced through CoinPay after the client approves them. A 10-hour minimum engagement replaces the week as the unit of commitment. The weekly price lived in 12 places, not the 3 the PRD listed: the front-page Hire Us section, the Top-Level Pages list, /hire-us metadata, /pricing (metadata, two FAQ answers, rate bullet), /about, llms.txt, skill.md, and the Hire Us form success message. Metered billing rather than a committed weekly block, because the old "recurring CoinPay invoice" copy documented a mechanic that never existed: /api/payments/create makes a single one-shot payment, not a subscription. - coinpay-checkout derives amount_usd from hours x 400 instead of a hardcoded 250, validates hours as quarter-hour increments at or above the minimum, and returns 422 before calling CoinPay on bad input. Payment metadata carries billing/hours/rate_usd_per_hour in place of interval. - project-request returns a rate, billing mode, and minimum; no amount exists until hours are approved. - CoinPay config block documents COINPAY_RATE_USD_PER_HOUR / COINPAY_BILLING / COINPAY_MINIMUM_HOURS instead of a weekly amount and interval. - New real /terms route replacing the SPA stub: what is billable, the approve-then-invoice flow, the minimum, cancellation on one week's notice, and an explicit clause that existing engagements keep their terms until both sides agree in writing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
|
|
// Client-side behavior for the LogicSRC page, ported from the legacy Vite
|
|
// main.ts: register the service worker, wire the hire-us project form, reflect
|
|
// CoinPay connection status on the Connect button, and scroll to the section
|
|
// that matches the current path. The markup these hooks target is rendered on
|
|
// the server by `renderPageMarkup`.
|
|
export function HomeInteractivity(): null {
|
|
useEffect(() => {
|
|
if ("serviceWorker" in navigator) {
|
|
navigator.serviceWorker.register("/service-worker.js").catch(() => undefined);
|
|
}
|
|
|
|
const buildParagraph = (text: string): HTMLParagraphElement => {
|
|
const paragraph = document.createElement("p");
|
|
paragraph.textContent = text;
|
|
return paragraph;
|
|
};
|
|
|
|
const form = document.querySelector<HTMLFormElement>("#project-request-form");
|
|
const onSubmit = async (event: Event): Promise<void> => {
|
|
event.preventDefault();
|
|
const button = document.querySelector<HTMLButtonElement>("#project-request-button");
|
|
const result = document.querySelector<HTMLDivElement>("#project-request-result");
|
|
const contact = document.querySelector<HTMLInputElement>("#project-contact");
|
|
const project = document.querySelector<HTMLTextAreaElement>("#project-description");
|
|
if (!button || !result || !contact || !project) return;
|
|
|
|
button.disabled = true;
|
|
button.textContent = "Submitting...";
|
|
result.replaceChildren(buildParagraph("Submitting project request."));
|
|
|
|
try {
|
|
const response = await fetch("/api/hire-us/project-request", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ contact: contact.value, project: project.value })
|
|
});
|
|
const payload = await response.json();
|
|
|
|
if (!response.ok || !payload.success) {
|
|
throw new Error(payload.error || "Project request could not be submitted.");
|
|
}
|
|
|
|
result.replaceChildren(
|
|
buildParagraph("Request received. If it is a fit, we will scope the work and invoice approved hours at $400/hour via CoinPay.")
|
|
);
|
|
} catch (error) {
|
|
result.replaceChildren(
|
|
buildParagraph(error instanceof Error ? error.message : "Project request could not be submitted.")
|
|
);
|
|
} finally {
|
|
button.disabled = false;
|
|
button.textContent = "Request review";
|
|
}
|
|
};
|
|
form?.addEventListener("submit", onSubmit);
|
|
|
|
// Scroll to the section that matches the current path (mirrors the SPA).
|
|
const { pathname } = window.location;
|
|
if (pathname === "/agent-swarm") {
|
|
document.querySelector("#agent-swarm")?.scrollIntoView();
|
|
} else if (pathname === "/agentbyte") {
|
|
document.querySelector("#agentbyte")?.scrollIntoView();
|
|
} else {
|
|
const pageRoute = pathname.slice(1);
|
|
if (
|
|
["docs", "blog", "openspec", "credential-sharing", "hire-us", "about", "terms", "privacy"].includes(pageRoute)
|
|
) {
|
|
document.querySelector(`#${pageRoute}`)?.scrollIntoView();
|
|
}
|
|
}
|
|
|
|
// CoinPay OAuth connection status: clean the query param and reflect state.
|
|
const coinpayParam = new URLSearchParams(window.location.search).get("coinpay_oauth");
|
|
if (coinpayParam) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete("coinpay_oauth");
|
|
url.searchParams.delete("error");
|
|
history.replaceState(null, "", url.pathname + (url.search || ""));
|
|
}
|
|
|
|
const updateCoinPayButton = async (): Promise<void> => {
|
|
const connectBtn = document.querySelector<HTMLAnchorElement>(
|
|
".hero-actions a[href='/api/oauth/coinpay/start']"
|
|
);
|
|
if (!connectBtn) return;
|
|
|
|
try {
|
|
const res = await fetch("/api/oauth/coinpay/session");
|
|
const data = await res.json();
|
|
|
|
if (data.authenticated && data.user) {
|
|
const label = data.user.email || data.user.name || data.user.sub || "CoinPay";
|
|
connectBtn.textContent = `Connected: ${label}`;
|
|
connectBtn.style.background = "#3a9e7e";
|
|
connectBtn.removeAttribute("href");
|
|
connectBtn.style.cursor = "default";
|
|
connectBtn.title = `Connected via CoinPay since ${new Date(data.user.connected_at).toLocaleDateString()}`;
|
|
} else if (coinpayParam === "connected") {
|
|
connectBtn.textContent = "CoinPay Connected";
|
|
connectBtn.style.background = "#3a9e7e";
|
|
} else if (coinpayParam === "error") {
|
|
connectBtn.textContent = "Connect CoinPay (retry)";
|
|
}
|
|
} catch {
|
|
// session check failed — leave button as-is
|
|
}
|
|
};
|
|
void updateCoinPayButton();
|
|
|
|
return () => {
|
|
form?.removeEventListener("submit", onSubmit);
|
|
};
|
|
}, []);
|
|
|
|
return null;
|
|
}
|