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
4 changes: 3 additions & 1 deletion frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@
"generating": "Generating...",
"generate": "Generate Payment Link",
"rateLimitError": "You're creating links too quickly. Try again in {seconds} seconds.",
"retryWait": "Wait {seconds}s…"
"retryWait": "Wait {seconds}s…",
"integrationCode": "Integration Code",
"snippetsHelper": "Code snippets update automatically with your form values."
},
"paymentMetrics": {
"downloadImage": "Download Image",
Expand Down
4 changes: 3 additions & 1 deletion frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@
"generating": "Generando...",
"generate": "Generar enlace de pago",
"rateLimitError": "Estás creando enlaces demasiado rápido. Inténtalo de nuevo en {seconds} segundos.",
"retryWait": "Espera {seconds}s…"
"retryWait": "Espera {seconds}s…",
"integrationCode": "Código de Integración",
"snippetsHelper": "Los fragmentos de código se actualizan automáticamente con los valores del formulario."
},
"paymentMetrics": {
"downloadImage": "Descargar imagen",
Expand Down
4 changes: 3 additions & 1 deletion frontend/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@
"generating": "Gerando...",
"generate": "Gerar link de pagamento",
"rateLimitError": "Você está criando links rápido demais. Tente novamente em {seconds} segundos.",
"retryWait": "Aguarde {seconds}s…"
"retryWait": "Aguarde {seconds}s…",
"integrationCode": "Código de Integração",
"snippetsHelper": "Os trechos de código são atualizados automaticamente com os valores do formulário."
},
"paymentMetrics": {
"downloadImage": "Baixar imagem",
Expand Down
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@sentry/nextjs": "^10.46.0",
"@stellar/freighter-api": "^1.7.1",
"@types/prismjs": "^1.26.6",
"@walletconnect/sign-client": "^2.23.9",
"@walletconnect/types": "^2.23.8",
"boring-avatars": "^2.0.4",
Expand All @@ -30,6 +31,7 @@
"next-intl": "^4.8.3",
"next-mdx-remote": "^5.0.0",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-confetti": "^6.4.0",
Expand All @@ -49,6 +51,7 @@
"@types/canvas-confetti": "^1.9.0",
"@types/event-source-polyfill": "^1.0.5",
"@types/node": "25.5.0",
"@types/prismjs": "^1.26.5",
"@types/react": "18.3.28",
"@types/react-dom": "^18.3.7",
"autoprefixer": "^10.4.19",
Expand Down
64 changes: 60 additions & 4 deletions frontend/src/components/CreatePaymentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { motion, AnimatePresence, type Variants } from "framer-motion";
import confetti from "canvas-confetti";
import CopyButton from "./CopyButton";
import IntegrationCodeSnippets from "./IntegrationCodeSnippets";
import toast from "react-hot-toast";
import Link from "next/link";
import {
Expand Down Expand Up @@ -338,6 +339,7 @@ function SuccessCard({ created, onReset, t }: SuccessCardProps) {

export default function CreatePaymentForm() {
const t = useTranslations("createPaymentForm");
const [view, setView] = useState<"form" | "code">("form");
const [amount, setAmount] = useLocalStorage("payment_amount", "");
const [asset, setAsset] = useLocalStorage<"XLM" | "USDC">(
"payment_asset",
Expand Down Expand Up @@ -545,15 +547,67 @@ export default function CreatePaymentForm() {
t={t}
/>
) : (
<motion.form
<motion.div
key="form"
variants={formVariants}
initial="visible"
exit="exit"
onSubmit={handleSubmit}
className="flex flex-col gap-6"
noValidate
>
{/* Tab bar */}
<div className="flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1">
<button
type="button"
onClick={() => setView("form")}
className={`relative flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
view === "form" ? "text-black" : "text-slate-400 hover:text-white"
}`}
>
{view === "form" && (
<motion.div
layoutId="view-tab-bg"
className="absolute inset-0 rounded-lg bg-mint"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
)}
<span className="relative z-10">{t("generate")}</span>
</button>
<button
type="button"
onClick={() => setView("code")}
className={`relative flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
view === "code" ? "text-black" : "text-slate-400 hover:text-white"
}`}
>
{view === "code" && (
<motion.div
layoutId="view-tab-bg"
className="absolute inset-0 rounded-lg bg-mint"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
)}
<span className="relative z-10">{t("integrationCode")}</span>
</button>
</div>

{view === "code" ? (
<IntegrationCodeSnippets
apiKey={apiKey!}
amount={amount}
asset={asset}
recipient={recipient}
description={description}
usdcIssuer={USDC_ISSUER}
/>
) : (
<motion.form
key="payment-form"
variants={formVariants}
initial="visible"
onSubmit={handleSubmit}
className="flex flex-col gap-6"
noValidate
>
{error && (
<motion.div
initial={{ opacity: 0, y: -8 }}
Expand Down Expand Up @@ -834,7 +888,9 @@ export default function CreatePaymentForm() {
)}
<div className="absolute inset-0 -z-10 bg-mint/20 opacity-0 blur-xl transition-opacity group-hover:opacity-100" />
</button>
</motion.form>
</motion.form>
)}
</motion.div>
)}
</AnimatePresence>
);
Expand Down
182 changes: 182 additions & 0 deletions frontend/src/components/IntegrationCodeSnippets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"use client";

import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { motion, AnimatePresence } from "framer-motion";
import CopyButton from "./CopyButton";
import Prism from "prismjs";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-python";

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";

interface IntegrationCodeSnippetsProps {
apiKey: string;
amount: string;
asset: "XLM" | "USDC";
recipient: string;
description: string;
usdcIssuer: string;
}

type Language = "curl" | "node" | "python";

const LANGUAGES: { id: Language; label: string; grammar: string }[] = [
{ id: "curl", label: "cURL", grammar: "bash" },
{ id: "node", label: "Node.js", grammar: "javascript" },
{ id: "python", label: "Python", grammar: "python" },
];

function generateSnippet(
lang: Language,
apiKey: string,
amount: string,
asset: "XLM" | "USDC",
recipient: string,
description: string,
usdcIssuer: string,
): string {
const numAmount = parseFloat(amount) || 0;
const safeRecipient =
recipient || "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
const safeDescription = description || "Payment for services";

const body: Record<string, unknown> = {
amount: numAmount,
asset,
recipient: safeRecipient,
};
if (asset === "USDC") body.asset_issuer = usdcIssuer;
if (description) body.description = safeDescription;

const jsonBody = JSON.stringify(body, null, 2);

switch (lang) {
case "curl":
return `curl -X POST "${API_URL}/api/create-payment" \\
-H "Content-Type: application/json" \\
-H "x-api-key: ${apiKey}" \\
-d '${jsonBody}'`;

case "node":
return `const response = await fetch("${API_URL}/api/create-payment", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": "${apiKey}",
},
body: JSON.stringify(${jsonBody}),
});

const data = await response.json();
console.log(data.payment_link);`;

case "python":
return `import requests

response = requests.post(
"${API_URL}/api/create-payment",
headers={
"Content-Type": "application/json",
"x-api-key": "${apiKey}",
},
json=${jsonBody},
)

data = response.json()
print(data["payment_link"])`;
}
}

export default function IntegrationCodeSnippets({
apiKey,
amount,
asset,
recipient,
description,
usdcIssuer,
}: IntegrationCodeSnippetsProps) {
const t = useTranslations("createPaymentForm");
const [activeTab, setActiveTab] = useState<Language>("curl");
const codeRef = useRef<HTMLElement>(null);

const snippet = useMemo(
() => generateSnippet(activeTab, apiKey, amount, asset, recipient, description, usdcIssuer),
[activeTab, apiKey, amount, asset, recipient, description, usdcIssuer],
);

useEffect(() => {
if (codeRef.current) {
Prism.highlightElement(codeRef.current);
}
}, [snippet, activeTab]);

const grammarMap: Record<Language, string> = {
curl: "bash",
node: "javascript",
python: "python",
};

return (
<div className="flex flex-col gap-4">
{/* Tab bar */}
<div className="flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1">
{LANGUAGES.map((lang) => (
<button
key={lang.id}
type="button"
onClick={() => setActiveTab(lang.id)}
className={`relative flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
activeTab === lang.id
? "text-black"
: "text-slate-400 hover:text-white"
}`}
>
{activeTab === lang.id && (
<motion.div
layoutId="snippet-tab-bg"
className="absolute inset-0 rounded-lg bg-mint"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
)}
<span className="relative z-10">{lang.label}</span>
</button>
))}
</div>

{/* Code block */}
<div className="group relative overflow-hidden rounded-xl border border-white/10 bg-[rgba(2,6,23,0.82)]">
{/* Copy button */}
<div className="absolute right-3 top-3 z-10 opacity-0 transition-opacity group-hover:opacity-100">
<CopyButton text={snippet} />
</div>

<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.2 }}
className="overflow-x-auto p-4"
>
<pre className="!m-0 !bg-transparent !p-0">
<code
ref={codeRef}
className={`language-${grammarMap[activeTab]} !bg-transparent`}
>
{snippet}
</code>
</pre>
</motion.div>
</AnimatePresence>
</div>

{/* Helper text */}
<p className="text-xs text-slate-500">
{t("snippetsHelper")}
</p>
</div>
);
}
38 changes: 37 additions & 1 deletion frontend/src/components/RecentPayments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import toast from "react-hot-toast";
import PaymentDetailModal from "@/components/PaymentDetailModal";
import ExportCsvButton from "@/components/ExportCsvButton";
import { localeToLanguageTag } from "@/i18n/config";
Expand Down Expand Up @@ -383,6 +384,21 @@ export default function RecentPayments({
setSelectedPayment(null);
};

const handleCopyId = async (id: string, event: React.MouseEvent) => {
event.stopPropagation();
try {
await navigator.clipboard.writeText(id);
} catch {
const el = document.createElement("textarea");
el.value = id;
document.body.appendChild(el);
el.select();
document.execCommand("copy");
document.body.removeChild(el);
}
toast.success("Intent ID copied to clipboard");
};

if (showSkeleton || loading) {
return (
<div className="rounded-xl border border-yellow-500/30 bg-yellow-500/10 p-8 text-center">
Expand Down Expand Up @@ -1003,7 +1019,27 @@ export default function RecentPayments({
</span>
</td>
<td className="px-4 py-3 font-medium text-white">
{payment.amount} {payment.asset}
<span className="inline-flex items-center gap-1.5">
{payment.amount} {payment.asset}
<button
onClick={(event) => handleCopyId(payment.id, event)}
className="text-slate-500 transition-colors hover:text-mint"
aria-label="Copy intent ID"
title={`Copy ${payment.id}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</span>
</td>
<td className="hidden px-4 py-3 text-slate-400 sm:table-cell">
<code className="font-mono text-xs text-slate-300">
Expand Down
Loading