diff --git a/app/actions/checkout.ts b/app/actions/checkout.ts
new file mode 100644
index 000000000..fa1ed44f3
--- /dev/null
+++ b/app/actions/checkout.ts
@@ -0,0 +1,36 @@
+"use server";
+
+import {
+ createCheckout,
+ type CreateCheckoutParams,
+} from "@/lib/rapyd/checkout";
+import { z } from "zod";
+
+const checkoutSchema = z.object({
+ amount: z.number().positive(),
+ merchantReferenceId: z.string(),
+ completeCheckoutUrl: z.string().url(),
+ cancelCheckoutUrl: z.string().url(),
+ description: z.string().optional(),
+});
+
+export const createCheckoutAction = async (data: CreateCheckoutParams) => {
+ try {
+ // Validate input
+ const validatedData = checkoutSchema.parse(data);
+
+ // Create checkout
+ const checkout = await createCheckout(validatedData);
+
+ return {
+ success: true,
+ data: checkout,
+ };
+ } catch (error) {
+ console.error("Checkout creation failed:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error occurred",
+ };
+ }
+};
diff --git a/app/components/checkout-button.tsx b/app/components/checkout-button.tsx
new file mode 100644
index 000000000..40602ab3e
--- /dev/null
+++ b/app/components/checkout-button.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { createCheckoutAction } from "@/app/actions/checkout";
+import { Button } from "@/components/ui/button";
+import { useRouter } from "next/navigation";
+import { useTransition } from "react";
+import { toast } from "sonner";
+
+interface CheckoutButtonProps {
+ amount: number;
+ description?: string;
+}
+
+export const CheckoutButton = ({
+ amount,
+ description,
+}: CheckoutButtonProps) => {
+ const [isPending, startTransition] = useTransition();
+ const router = useRouter();
+
+ const handleCheckout = () => {
+ startTransition(async () => {
+ const merchantReferenceId = crypto.randomUUID();
+
+ const result = await createCheckoutAction({
+ amount,
+ merchantReferenceId,
+ completeCheckoutUrl: `${window.location.origin}/checkout/complete`,
+ cancelCheckoutUrl: `${window.location.origin}/checkout/cancel`,
+ description,
+ });
+
+ if (!result.success) {
+ toast.error("Failed to create checkout session");
+ return;
+ }
+
+ // Redirect to Rapyd checkout page
+ router.push(result.data.redirect_url);
+ });
+ };
+
+ return (
+
+ );
+};
diff --git a/lib/rapyd/checkout.ts b/lib/rapyd/checkout.ts
new file mode 100644
index 000000000..640bd969c
--- /dev/null
+++ b/lib/rapyd/checkout.ts
@@ -0,0 +1,69 @@
+import { makeRequest } from "@/lib/rapyd/utilities";
+import { cache } from "react";
+import "server-only";
+
+// Icelandic card payment methods
+const ICELANDIC_PAYMENT_METHODS = [
+ "is_visa_card",
+ "is_mastercard_card",
+] as const;
+
+interface CheckoutResponse {
+ id: string;
+ redirect_url: string;
+ status: string;
+ payment: {
+ id: string;
+ amount: number;
+ currency: string;
+ status: string;
+ };
+}
+
+interface CreateCheckoutParams {
+ amount: number;
+ merchantReferenceId: string;
+ completeCheckoutUrl: string;
+ cancelCheckoutUrl: string;
+ description?: string;
+}
+
+const DEFAULT_CHECKOUT_CONFIG = {
+ country: "IS",
+ currency: "ISK",
+} as const;
+
+export const preloadCheckout = (params: CreateCheckoutParams) => {
+ void createCheckout(params);
+};
+
+export const createCheckout = cache(
+ async ({
+ amount,
+ merchantReferenceId,
+ completeCheckoutUrl,
+ cancelCheckoutUrl,
+ description,
+ }: CreateCheckoutParams): Promise => {
+ const checkoutBody = {
+ amount,
+ merchant_reference_id: merchantReferenceId,
+ complete_checkout_url: completeCheckoutUrl,
+ cancel_checkout_url: cancelCheckoutUrl,
+ country: DEFAULT_CHECKOUT_CONFIG.country,
+ currency: DEFAULT_CHECKOUT_CONFIG.currency,
+ payment_method_types_include: ICELANDIC_PAYMENT_METHODS,
+ ...(description && { description }),
+ };
+
+ const response = await makeRequest({
+ method: "post",
+ path: "/v1/checkout",
+ body: checkoutBody,
+ });
+
+ return response as CheckoutResponse;
+ }
+);
+
+export type { CheckoutResponse, CreateCheckoutParams };
diff --git a/lib/rapyd/utilities.ts b/lib/rapyd/utilities.ts
new file mode 100644
index 000000000..c7ffd2846
--- /dev/null
+++ b/lib/rapyd/utilities.ts
@@ -0,0 +1,147 @@
+import crypto from "crypto";
+
+const BASE_URL = process.env.RAPYD_BASE_URL;
+const SECRET_KEY = process.env.RAPYD_SECRET_KEY;
+const ACCESS_KEY = process.env.RAPYD_ACCESS_KEY;
+
+if (!SECRET_KEY || !ACCESS_KEY) {
+ throw new Error("RAPYD_SECRET_KEY and RAPYD_ACCESS_KEY must be set");
+}
+
+type HttpMethod = "get" | "put" | "post" | "delete";
+
+interface SignatureHeaders {
+ access_key: string;
+ salt: string;
+ timestamp: string;
+ signature: string;
+ idempotency: string;
+}
+
+interface RequestConfig {
+ method: HttpMethod;
+ path: string;
+ body?: Record;
+}
+
+const generateSalt = (length = 12): string => {
+ const characters =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ return Array.from(
+ { length },
+ () => characters[Math.floor(Math.random() * characters.length)]
+ ).join("");
+};
+
+const getUnixTime = ({
+ days = 0,
+ hours = 0,
+ minutes = 0,
+ seconds = 0,
+} = {}): number => {
+ const now = new Date();
+ now.setDate(now.getDate() + days);
+ now.setHours(now.getHours() + hours);
+ now.setMinutes(now.getMinutes() + minutes);
+ now.setSeconds(now.getSeconds() + seconds);
+ return Math.floor(now.getTime() / 1000);
+};
+
+const updateTimestampSaltSig = ({
+ method,
+ path,
+ body,
+}: RequestConfig): {
+ salt: string;
+ timestamp: number;
+ signature: string;
+} => {
+ const normalizedPath = path.startsWith("http")
+ ? path.substring(path.indexOf("/v1"))
+ : path;
+
+ const salt = generateSalt();
+ const timestamp = getUnixTime();
+ const bodyString = body ? JSON.stringify(body) : "";
+
+ const toSign = [
+ method,
+ normalizedPath,
+ salt,
+ timestamp.toString(),
+ ACCESS_KEY,
+ SECRET_KEY,
+ bodyString,
+ ].join("");
+
+ const hmac = crypto.createHmac("sha256", SECRET_KEY);
+ hmac.update(toSign);
+ const signature = Buffer.from(hmac.digest("hex")).toString("base64url");
+
+ return { salt, timestamp, signature };
+};
+
+const createHeaders = ({
+ method,
+ path,
+ body,
+}: RequestConfig): {
+ headers: SignatureHeaders;
+ body: string;
+} => {
+ const { salt, timestamp, signature } = updateTimestampSaltSig({
+ method,
+ path,
+ body,
+ });
+
+ const headers: SignatureHeaders = {
+ access_key: ACCESS_KEY,
+ salt,
+ timestamp: timestamp.toString(),
+ signature,
+ idempotency: `${getUnixTime()}${salt}`,
+ };
+
+ return {
+ headers,
+ body: body ? JSON.stringify(body) : "",
+ };
+};
+
+const makeRequest = async ({
+ method,
+ path,
+ body,
+}: RequestConfig): Promise => {
+ const { headers, body: stringifiedBody } = createHeaders({
+ method,
+ path,
+ body,
+ });
+ const url = `${BASE_URL}${path}`;
+
+ const requestConfig: RequestInit = {
+ method: method.toUpperCase(),
+ headers: {
+ ...headers,
+ "Content-Type": "application/json",
+ },
+ };
+
+ if (method !== "get" && stringifiedBody) {
+ requestConfig.body = stringifiedBody;
+ }
+
+ const response = await fetch(url, requestConfig);
+
+ if (!response.ok) {
+ throw new Error(
+ `Request failed: ${response.status} ${response.statusText}`
+ );
+ }
+
+ return response.json();
+};
+
+export { makeRequest, type HttpMethod, type RequestConfig };
diff --git a/package.json b/package.json
index 0a3ceea07..bdc6c8040 100644
--- a/package.json
+++ b/package.json
@@ -13,10 +13,11 @@
"@heroicons/react": "^2.2.0",
"clsx": "^2.1.1",
"geist": "^1.3.1",
- "next": "15.2.0-canary.67",
+ "next": "15.3.0-canary.9",
"react": "19.0.0",
"react-dom": "19.0.0",
- "sonner": "^2.0.1"
+ "sonner": "^2.0.1",
+ "zod": "^3.24.2"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
@@ -30,5 +31,11 @@
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.8",
"typescript": "5.7.3"
+ },
+ "pnpm": {
+ "overrides": {
+ "@types/react": "19.0.10",
+ "@types/react-dom": "19.0.4"
+ }
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1f445e255..0a1c2da1d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+overrides:
+ '@types/react': 19.0.10
+ '@types/react-dom': 19.0.4
+
importers:
.:
@@ -19,10 +23,10 @@ importers:
version: 2.1.1
geist:
specifier: ^1.3.1
- version: 1.3.1(next@15.2.0-canary.67(react-dom@19.0.0(react@19.0.0))(react@19.0.0))
+ version: 1.3.1(next@15.3.0-canary.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))
next:
- specifier: 15.2.0-canary.67
- version: 15.2.0-canary.67(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ specifier: 15.3.0-canary.9
+ version: 15.3.0-canary.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react:
specifier: 19.0.0
version: 19.0.0
@@ -32,6 +36,9 @@ importers:
sonner:
specifier: ^2.0.1
version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ zod:
+ specifier: ^3.24.2
+ version: 3.24.2
devDependencies:
'@tailwindcss/container-queries':
specifier: ^0.1.1
@@ -214,53 +221,53 @@ packages:
cpu: [x64]
os: [win32]
- '@next/env@15.2.0-canary.67':
- resolution: {integrity: sha512-Is8AU8GcBrDoyXTmEKPTM+K87Xb5SA545jkw0E6+51Zb/1sg5MSCH9OmQf2KlvbqSrkiVuQ8UA23pY5+xGFGpw==}
+ '@next/env@15.3.0-canary.9':
+ resolution: {integrity: sha512-kvABHn6GmJbyf02wozUzrC4evHdVSmc6FYV8I7Q4g3qZW1x64v6ppi3Hw1KEUzKieC1Car/maGT+r3oRalCg4Q==}
- '@next/swc-darwin-arm64@15.2.0-canary.67':
- resolution: {integrity: sha512-BNBt0qWhnZR2pSxlofSBsmy5PYRa7/t4txnYH5z41lSs0B9OlhlsYyiokefmiw6EKKLxzT23hmb+ZPtxTCjiFw==}
+ '@next/swc-darwin-arm64@15.3.0-canary.9':
+ resolution: {integrity: sha512-llJnHJGXQGux7sHJ4t0q5HbMnID+M3+s5ghvYBw79uP4QDkH5XVXRC2oQUwTvEPzHXUhWpB/kf6KUpWmOEI8xQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@15.2.0-canary.67':
- resolution: {integrity: sha512-ZPC0/iL3dhexN+MQ6QOfaOO6y3WMhyxns01KA9mae0V0sp/uC+KwpSbNCVXptjmiZQ9j0Q9TYjqQBQ4KwtBK8Q==}
+ '@next/swc-darwin-x64@15.3.0-canary.9':
+ resolution: {integrity: sha512-igGqAeBB/3tJ4XLqbdcuzbgwgdNh9kRp2AFSME/Ok4jyetSPmcQFX43+C6piuMj2gQ06Q6gDWj3qib0MNf5IWw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@15.2.0-canary.67':
- resolution: {integrity: sha512-t+i9tRB0uYj3OoZS2qhPwDrlcf5bRTKQY5GtFwboyCzBLv9KcU1xa3cwXulNxq1soo8wTiWRnhq8CkvUT+Fbvw==}
+ '@next/swc-linux-arm64-gnu@15.3.0-canary.9':
+ resolution: {integrity: sha512-Ym9FxqbBmQyUjoe2bW7MsDkrYV3sSR8WXCEqJQthETjAqSsG6zwUfL86dMAKe2RUetqlNzWlXDH/+FM9fdPVOw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@next/swc-linux-arm64-musl@15.2.0-canary.67':
- resolution: {integrity: sha512-9jffwDN4X2ER5eqj16XJHCf4MmRI3QI0L52JLYH9/3OPxD86bDeQlH/+NK3iEu/3X4KX1rDb7cF9uulB9hjfXw==}
+ '@next/swc-linux-arm64-musl@15.3.0-canary.9':
+ resolution: {integrity: sha512-aB9umTo1HHcQWRTXffWSrt6wTMvhg+fYbtZ8PR7h28gBrQaYL6Lu8Kg7BQynYEx8Ze42GqVcS0MlwVsTQrpwMw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@next/swc-linux-x64-gnu@15.2.0-canary.67':
- resolution: {integrity: sha512-pWp5NIAbMLKy6tfZF22tsDC3A9IJm/p+UQf9l906NClYKtMXLYDFmapXVpTUB7fdb9xDOvB+DtAX11nQk5bukw==}
+ '@next/swc-linux-x64-gnu@15.3.0-canary.9':
+ resolution: {integrity: sha512-d+tU/H5SaPAuHcxGJ9fiqt0qzXpkOmksu1lF9JQNHd6WKtBnnJMzpYL8onLLYXThrIPaETVSLpBiv1wvwIgwFg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@next/swc-linux-x64-musl@15.2.0-canary.67':
- resolution: {integrity: sha512-RjSu9pEgQuUmkt1FINCCvpV0SHYrLpf7LaF7pZPem1N2lgDySnazt4ag7ZDaWL0XMBiTKGmNxkk185HeST2PSg==}
+ '@next/swc-linux-x64-musl@15.3.0-canary.9':
+ resolution: {integrity: sha512-b+V+36WIopplWQI2/xOIqzuRGCRGTDLVe2luhhtjcwewRqUujktGnphHW5zRcEVD9nNwwPCisxC01XLL3geggg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@next/swc-win32-arm64-msvc@15.2.0-canary.67':
- resolution: {integrity: sha512-DM+ysK87Q10MkoxgZeFOZsRx4Yt1WtynDVZoogdEjikfxZrMLCEo22O2uFVNh2E0kHCdE89K3dODO9rQz9EjAw==}
+ '@next/swc-win32-arm64-msvc@15.3.0-canary.9':
+ resolution: {integrity: sha512-6YbKTAP1Z+dnFtEoPQc4NuQ9J3VIN0vc8gHmZHBl5qfBQgF9f4DfBwcTrXMXEKIFVkQN4YMZU83v+2DSzT+7FQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@15.2.0-canary.67':
- resolution: {integrity: sha512-gI3Hk/6YrFXMJn018ZjZo832Pxrsj2DpyXbLc9Vxs4wOZ0x3ChVk2yhFA/SJZY7yhdD3vwG9Srdn8gfCuO4xHg==}
+ '@next/swc-win32-x64-msvc@15.3.0-canary.9':
+ resolution: {integrity: sha512-Ujf4+i1memQV3Qk0EjY00C4bzumV6jOZze9kCdi4PnpPjzEefTj88CFGR7ACmYgu1qDHOKaZQxR08MALy/yvIw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -406,7 +413,7 @@ packages:
'@types/react-dom@19.0.4':
resolution: {integrity: sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==}
peerDependencies:
- '@types/react': ^19.0.0
+ '@types/react': 19.0.10
'@types/react@19.0.10':
resolution: {integrity: sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==}
@@ -553,8 +560,8 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
- next@15.2.0-canary.67:
- resolution: {integrity: sha512-hKZjcmngKiY/HzHXNN0SGXR9Xio6pirraWu8GqRD24pMiGueY0ykxTbOp0SrqgHDsgWGTOxy3hXCriGiyyVb8w==}
+ next@15.3.0-canary.9:
+ resolution: {integrity: sha512-R9+FanTpLPN4cez/lJurj/kedcOERPCQebl/F5kevPSzCQzp8Dj/LCv6L10wTqBH3zBgqepp0eytzsVrjW8VjA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@@ -724,6 +731,9 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ zod@3.24.2:
+ resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
+
snapshots:
'@alloc/quick-lru@5.2.0': {}
@@ -846,30 +856,30 @@ snapshots:
'@img/sharp-win32-x64@0.33.5':
optional: true
- '@next/env@15.2.0-canary.67': {}
+ '@next/env@15.3.0-canary.9': {}
- '@next/swc-darwin-arm64@15.2.0-canary.67':
+ '@next/swc-darwin-arm64@15.3.0-canary.9':
optional: true
- '@next/swc-darwin-x64@15.2.0-canary.67':
+ '@next/swc-darwin-x64@15.3.0-canary.9':
optional: true
- '@next/swc-linux-arm64-gnu@15.2.0-canary.67':
+ '@next/swc-linux-arm64-gnu@15.3.0-canary.9':
optional: true
- '@next/swc-linux-arm64-musl@15.2.0-canary.67':
+ '@next/swc-linux-arm64-musl@15.3.0-canary.9':
optional: true
- '@next/swc-linux-x64-gnu@15.2.0-canary.67':
+ '@next/swc-linux-x64-gnu@15.3.0-canary.9':
optional: true
- '@next/swc-linux-x64-musl@15.2.0-canary.67':
+ '@next/swc-linux-x64-musl@15.3.0-canary.9':
optional: true
- '@next/swc-win32-arm64-msvc@15.2.0-canary.67':
+ '@next/swc-win32-arm64-msvc@15.3.0-canary.9':
optional: true
- '@next/swc-win32-x64-msvc@15.2.0-canary.67':
+ '@next/swc-win32-x64-msvc@15.3.0-canary.9':
optional: true
'@react-aria/focus@3.19.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
@@ -1059,9 +1069,9 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.2.1
- geist@1.3.1(next@15.2.0-canary.67(react-dom@19.0.0(react@19.0.0))(react@19.0.0)):
+ geist@1.3.1(next@15.3.0-canary.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)):
dependencies:
- next: 15.2.0-canary.67(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ next: 15.3.0-canary.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
graceful-fs@4.2.11: {}
@@ -1123,9 +1133,9 @@ snapshots:
nanoid@3.3.8: {}
- next@15.2.0-canary.67(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ next@15.3.0-canary.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
- '@next/env': 15.2.0-canary.67
+ '@next/env': 15.3.0-canary.9
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@@ -1135,14 +1145,14 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(react@19.0.0)
optionalDependencies:
- '@next/swc-darwin-arm64': 15.2.0-canary.67
- '@next/swc-darwin-x64': 15.2.0-canary.67
- '@next/swc-linux-arm64-gnu': 15.2.0-canary.67
- '@next/swc-linux-arm64-musl': 15.2.0-canary.67
- '@next/swc-linux-x64-gnu': 15.2.0-canary.67
- '@next/swc-linux-x64-musl': 15.2.0-canary.67
- '@next/swc-win32-arm64-msvc': 15.2.0-canary.67
- '@next/swc-win32-x64-msvc': 15.2.0-canary.67
+ '@next/swc-darwin-arm64': 15.3.0-canary.9
+ '@next/swc-darwin-x64': 15.3.0-canary.9
+ '@next/swc-linux-arm64-gnu': 15.3.0-canary.9
+ '@next/swc-linux-arm64-musl': 15.3.0-canary.9
+ '@next/swc-linux-x64-gnu': 15.3.0-canary.9
+ '@next/swc-linux-x64-musl': 15.3.0-canary.9
+ '@next/swc-win32-arm64-msvc': 15.3.0-canary.9
+ '@next/swc-win32-x64-msvc': 15.3.0-canary.9
sharp: 0.33.5
transitivePeerDependencies:
- '@babel/core'
@@ -1244,3 +1254,5 @@ snapshots:
undici-types@6.20.0: {}
util-deprecate@1.0.2: {}
+
+ zod@3.24.2: {}
diff --git a/tsconfig.json b/tsconfig.json
index 5504152f4..a52be867f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -21,7 +21,10 @@
{
"name": "next"
}
- ]
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]