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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/app/api/numbers/orders/[id]/reopen/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { createServiceClient } from "@/lib/supabase/server";
import { resolveUser } from "@/lib/request-user";
import { OrderError, reopenOrder } from "@/lib/managed-numbers/service";
import { getBrandSiteUrl } from "@/lib/brand-server";
import { checkRateLimit } from "@/lib/rate-limit";

/** Finish an expired order: { chain? } -> a fresh checkout for the same number and months. */
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const who = await resolveUser(request);
if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status });
const rl = checkRateLimit(`number-order:${who.user.userId}`, { limit: 10, windowMs: 60 * 60 * 1000 });
if (!rl.allowed) return NextResponse.json({ error: "Too many checkouts, try again later" }, { status: 429 });
const { id } = await params;
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
try {
const order = await reopenOrder(
{ db: createServiceClient() },
{
userId: who.user.userId,
email: who.user.email,
orderId: id,
chain: body.chain,
siteUrl: await getBrandSiteUrl(request),
}
);
return NextResponse.json({ order }, { status: 201 });
} catch (e) {
if (e instanceof OrderError) return NextResponse.json({ error: e.message }, { status: e.status });
console.error("reopen number order error:", e);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
15 changes: 15 additions & 0 deletions src/app/inbox/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { InboxClient } from "@/components/inbox-client";
import { StationInbox } from "@/components/station/station-inbox";
import { getBrand } from "@/lib/brand-server";
import { priceUsdPerMonth } from "@/lib/plans";
import { unfinishedOrder } from "@/lib/managed-numbers/recovery";

export default async function InboxPage() {
const supabase = await createServerSupabaseClient();
Expand Down Expand Up @@ -36,6 +37,15 @@ export default async function InboxPage() {
.eq("status", "active")
.order("created_at", { ascending: true });

// An order that expired unpaid gets a one-click "Finish your order" here.
const { data: orders } = await supabase
.from("number_orders")
.select("id, kind, status, area_code, months, amount_usd, chain, error, created_at")
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.limit(5);
const unfinished = unfinishedOrder(orders || []);

if ((await getBrand()).id === "numberstation") {
return <StationInbox conversations={conversations || []} numbers={numbers || []} />;
}
Expand All @@ -46,6 +56,11 @@ export default async function InboxPage() {
numbers={numbers || []}
userId={user.id}
rentPriceUsd={priceUsdPerMonth()}
unfinishedOrder={
unfinished
? { id: unfinished.id, amountUsd: Number(unfinished.amount_usd), months: unfinished.months, chain: unfinished.chain }
: null
}
/>
);
}
127 changes: 94 additions & 33 deletions src/app/numbers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { LineNameEditor } from "@/components/line-name-editor";
import { CHECKOUT_WINDOW_MINUTES, checkoutWindowOpen } from "@/lib/coinpay/window";
import { safePayUrl, unfinishedOrder } from "@/lib/managed-numbers/recovery";

interface NumberRow {
id: string;
Expand Down Expand Up @@ -57,6 +58,13 @@ const CHAIN_LABELS: Record<string, string> = {
SOL: "SOL",
ETH: "ETH",
BTC: "BTC",
USDC_BASE: "USDC (Base)",
USDT_TRON: "USDT (TRON)",
BCH: "Bitcoin Cash (BCH)",
DOGE: "Dogecoin (DOGE)",
XRP: "XRP",
ADA: "Cardano (ADA)",
BNB: "BNB (BNB Smart Chain)",
};

const STATUS_TEXT: Record<OrderRow["status"], string> = {
Expand Down Expand Up @@ -85,18 +93,6 @@ function statusText(o: OrderRow, now: Date): string {
return STATUS_TEXT[o.status];
}

/** Only ever send the browser to CoinPay's own payment page. */
function safePayUrl(raw: unknown): string | null {
try {
const u = new URL(String(raw));
return u.protocol === "https:" && u.hostname === "coinpayportal.com" && u.pathname.startsWith("/pay/")
? u.toString()
: null;
} catch {
return null;
}
}

function fmtDate(iso: string | null) {
return iso ? new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : "";
}
Expand Down Expand Up @@ -199,10 +195,12 @@ export default function NumbersPage() {
const rented = data.numbers.filter((n) => n.managed);
// A checkout still inside its window: send people back to it instead of opening another.
const openCheckout = data.orders.find((o) => payable(o, now) && safePayUrl(o.pay_url));
const retry = (o: OrderRow) =>
o.kind === "renew" && o.phone_number_id
? checkout(`/api/numbers/${o.phone_number_id}/renew`, { months: o.months, chain: o.chain })
: checkout("/api/numbers/orders", { area_code: o.area_code ?? "", months: o.months, chain: o.chain });
// One click: the same number and months, a fresh checkout window.
const retry = (o: OrderRow, method?: string) =>
checkout(`/api/numbers/orders/${o.id}/reopen`, method ? { chain: method } : {});
const unfinished = !openCheckout && data.ordering.open ? unfinishedOrder(data.orders, now) : null;
const coins = data.pricing.chains.filter((c) => c !== "PAYPAL");
const payByPaypal = chain === "PAYPAL";

return (
<div className="min-h-screen max-w-3xl mx-auto px-4 py-8 space-y-8">
Expand Down Expand Up @@ -238,6 +236,36 @@ export default function NumbersPage() {
</div>
)}

{unfinished && unfinished.id !== watched?.id && (
<div className="rounded-xl border border-yellow-500/30 bg-yellow-500/10 p-4 text-sm text-yellow-100 space-y-3">
<div>
Your order for {unfinished.kind === "renew" ? "a renewal" : unfinished.area_code ? `a ${unfinished.area_code} number` : "a US number"}{" "}
(${Number(unfinished.amount_usd).toFixed(2)}, {unfinished.months} mo) was not paid before the checkout closed.
Nothing was charged. Pick it up where you left off:
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy}
onClick={() => retry(unfinished)}
className="rounded-lg bg-green-600 px-4 py-2 font-semibold text-white hover:bg-green-700 disabled:opacity-50"
>
Finish your order ({CHAIN_LABELS[unfinished.chain] || unfinished.chain})
</button>
{unfinished.chain !== "PAYPAL" && (
<button
type="button"
disabled={busy}
onClick={() => retry(unfinished, "PAYPAL")}
className="rounded-lg bg-[#ffc439] px-4 py-2 font-semibold text-gray-900 hover:bg-[#f2b92f] disabled:opacity-50"
>
Pay with PayPal instead
</button>
)}
</div>
</div>
)}

{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-300">{error}</div>
)}
Expand Down Expand Up @@ -320,8 +348,8 @@ export default function NumbersPage() {
<section className="rounded-xl border border-gray-800 bg-gray-900 p-6 space-y-4">
<h2 className="text-lg font-semibold">Rent a number</h2>
<p className="text-sm text-gray-400">
A US number that receives texts and verification codes, ${price}/month, paid in crypto or with PayPal
through CoinPay.
A US number that receives texts and verification codes, ${price}/month, paid with PayPal or in
any of {coins.length} cryptocurrencies through CoinPay.
It is set up as soon as the payment confirms. Rented numbers receive only; some services refuse
virtual numbers.
</p>
Expand All @@ -341,15 +369,40 @@ export default function NumbersPage() {
PayPal&apos;s page. The number is set up once PayPal confirms the payment.
</li>
<li>
The payment page stays open for {CHECKOUT_WINDOW_MINUTES} minutes. If it closes before you pay, start
a new checkout. If you already sent the coins, do not pay again: a late payment is still credited,
The payment page stays open for {CHECKOUT_WINDOW_MINUTES} minutes. If it closes before you pay, press
Finish your order here and you get a fresh one in one click. If you already sent the coins, do not pay again: a late payment is still credited,
it only takes longer.
</li>
</ol>
</div>
{!data.ordering.open ? (
<p className="text-sm text-yellow-300">{data.ordering.reason}</p>
) : (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2" role="radiogroup" aria-label="How to pay">
<button
type="button"
role="radio"
aria-checked={payByPaypal}
onClick={() => setChain("PAYPAL")}
className={`rounded-lg border px-4 py-3 text-left ${payByPaypal ? "border-[#ffc439] bg-[#ffc439]/10" : "border-gray-700 bg-gray-950 hover:border-gray-500"}`}
>
<div className="font-semibold text-white">PayPal</div>
<div className="text-xs text-gray-400">No crypto needed. Pay on PayPal&apos;s own page.</div>
</button>
<button
type="button"
role="radio"
aria-checked={!payByPaypal}
onClick={() => setChain(data.pricing.default_chain)}
className={`rounded-lg border px-4 py-3 text-left ${!payByPaypal ? "border-green-500 bg-green-500/10" : "border-gray-700 bg-gray-950 hover:border-gray-500"}`}
>
<div className="font-semibold text-white">Crypto</div>
<div className="text-xs text-gray-400">
{coins.length} coins: USDC, USDT, BTC, ETH, SOL and more.
</div>
</button>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
Expand All @@ -375,25 +428,33 @@ export default function NumbersPage() {
</option>
))}
</select>
<select
value={chain}
onChange={(e) => setChain(e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-950 px-3 py-2"
>
{data.pricing.chains.map((c) => (
<option key={c} value={c}>
{CHAIN_LABELS[c] || c}
</option>
))}
</select>
{payByPaypal ? (
<div className="flex items-center rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-gray-300">
PayPal
</div>
) : (
<select
value={chain}
onChange={(e) => setChain(e.target.value)}
aria-label="Coin"
className="rounded-lg border border-gray-700 bg-gray-950 px-3 py-2"
>
{coins.map((c) => (
<option key={c} value={c}>
{CHAIN_LABELS[c] || c}
</option>
))}
</select>
)}
<button
type="submit"
disabled={busy}
className="rounded-lg bg-green-600 px-4 py-2 font-semibold hover:bg-green-700 disabled:opacity-50"
>
{busy ? "Opening..." : `Pay $${(price * months).toFixed(2)}`}
{busy ? "Opening..." : `Pay $${(price * months).toFixed(2)}${payByPaypal ? " with PayPal" : ""}`}
</button>
</form>
</div>
)}
<p className="text-xs text-gray-500">
Plan: {data.plan.plan === "pro" ? "Pro" : "Free"}. Renting a number unlocks Pro limits while it is active.
Expand Down Expand Up @@ -429,7 +490,7 @@ export default function NumbersPage() {
onClick={() => retry(o)}
className="ml-2 underline disabled:opacity-50"
>
Start a new checkout
Finish this order
</button>
)}
</div>
Expand Down
68 changes: 66 additions & 2 deletions src/components/inbox-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,64 @@
import { ContactNameEditor } from "./contact-name-editor";
import { Logo } from "./logo";
import type { LineNumber } from "@/lib/inbox-lines";
import { safePayUrl } from "@/lib/managed-numbers/recovery";

interface UnfinishedOrder {
id: string;
amountUsd: number;
months: number;
chain: string;
}

/** One click back into an expired, unpaid number order. */
function FinishOrderBanner({ order }: { order: UnfinishedOrder }) {
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const go = async (chain?: string) => {
setBusy(true);
setErr(null);
try {
const res = await fetch(`/api/numbers/orders/${order.id}/reopen`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(chain ? { chain } : {}),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || "Could not reopen the checkout");
const pay = safePayUrl(j.order?.pay_url);
window.location.href = pay || "/numbers";
} catch (e) {
setErr(e instanceof Error ? e.message : "Could not reopen the checkout");
setBusy(false);
}
};
return (
<div className="border-b border-yellow-500/30 bg-yellow-500/10 px-4 py-2 text-sm text-yellow-100 flex flex-wrap items-center gap-2">
<span>
Your ${order.amountUsd.toFixed(2)} number order ({order.months} mo) was not paid before the checkout closed.
</span>
<button
type="button"
disabled={busy}
onClick={() => go()}
className="rounded bg-green-600 px-3 py-1 font-semibold text-white hover:bg-green-700 disabled:opacity-50"
>
{busy ? "Opening..." : "Finish your order"}
</button>
{order.chain !== "PAYPAL" && (
<button
type="button"
disabled={busy}
onClick={() => go("PAYPAL")}
className="rounded bg-[#ffc439] px-3 py-1 font-semibold text-gray-900 hover:bg-[#f2b92f] disabled:opacity-50"
>
Pay with PayPal
</button>
)}
{err && <span className="text-red-300">{err}</span>}
</div>
);
}

function MessageStatusIcon({ status, retryCount }: { status: string; retryCount?: number }) {
if (status === "failed" && retryCount && retryCount > 0) {
Expand All @@ -31,12 +89,15 @@
numbers = [],
userId: _userId,
rentPriceUsd = 5,
unfinishedOrder = null,
}: {
conversations: Conversation[];
numbers?: LineNumber[];
userId: string;
/** Monthly price of a rented number, shown to accounts with no number yet. */
rentPriceUsd?: number;
/** An order that expired unpaid, offered back in one click. */
unfinishedOrder?: UnfinishedOrder | null;
}) {
const {
filteredConversations,
Expand Down Expand Up @@ -132,7 +193,9 @@
}, [showNewMessage, searchQuery, filteredConversations]);

return (
<div className="h-screen flex">
<div className="h-screen flex flex-col">
{unfinishedOrder && <FinishOrderBanner order={unfinishedOrder} />}
<div className="flex flex-1 min-h-0">
{/* New Message Modal */}
<NewMessageModal
isOpen={showNewMessage}
Expand Down Expand Up @@ -426,7 +489,7 @@
<div className="text-4xl">📱</div>
<h2 className="text-xl font-semibold text-white">Get a number for your verification codes</h2>
<p className="text-sm text-gray-300">
Rent your own US number for ${rentPriceUsd}/month, paid in crypto through CoinPay. It is ready once
Rent your own US number for ${rentPriceUsd}/month, paid with PayPal or crypto through CoinPay. It is ready once
the payment confirms, and every text and code lands right here.
</p>
<a
Expand All @@ -451,6 +514,7 @@
</div>
)}
</div>
</div>
</div>
);
}
Loading
Loading