summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--api/Order.d.ts7
-rw-r--r--api/_env.ts48
-rw-r--r--api/_paypal.ts114
-rw-r--r--api/createPaypalOrder.ts28
-rw-r--r--package-lock.json12
-rw-r--r--package.json3
-rw-r--r--public/main.css2
-rw-r--r--public/normalize.css2
-rw-r--r--src/layouts/Layout.astro4
-rw-r--r--src/lib/localeStaticPaths.ts10
-rw-r--r--src/pages/[locale]/index.astro4
12 files changed, 228 insertions, 7 deletions
diff --git a/.gitignore b/.gitignore
index 3699d9a..7f25ed9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ npm-debug.log*
.vscode
dist/
.astro/
+.vercel
diff --git a/api/Order.d.ts b/api/Order.d.ts
new file mode 100644
index 0000000..cf1ff81
--- /dev/null
+++ b/api/Order.d.ts
@@ -0,0 +1,7 @@
+type Order = {
+ productDescription: string;
+ shortDescription: string;
+ totalPrice: number;
+};
+
+export default Order;
diff --git a/api/_env.ts b/api/_env.ts
new file mode 100644
index 0000000..69ed902
--- /dev/null
+++ b/api/_env.ts
@@ -0,0 +1,48 @@
+import path from 'path';
+import dotenv from 'dotenv';
+
+// Parsing the env file.
+dotenv.config({ path: path.resolve(__dirname, '../../.env') });
+
+type Env = {
+ PAYPAL_LIVE_CLIENT_ID: string,
+ PAYPAL_LIVE_SECRET: string,
+ PAYPAL_SANDBOX_CLIENT_ID: string,
+ PAYPAL_SANDBOX_SECRET: string,
+ ENVIRONMENT: 'dev' | 'prod',
+};
+
+type DirtyEnv = { [key in keyof Env]?: string };
+
+const ENV: { [key in keyof Env]: number } = {
+ PAYPAL_LIVE_CLIENT_ID: 1,
+ PAYPAL_LIVE_SECRET: 1,
+ PAYPAL_SANDBOX_CLIENT_ID: 1,
+ PAYPAL_SANDBOX_SECRET: 1,
+ ENVIRONMENT: 1,
+};
+
+const { env }: { env: DirtyEnv } = process;
+
+const sanitisedEnvEntries =
+ (Object.keys(ENV) as (keyof Env)[])
+ .map<[keyof Env, string]>(key => {
+ const value = env[key];
+ if (!value) {
+ throw new Error(`Environment not configured correctly. ${key} was not defined.`);
+ }
+
+ if (key === 'ENVIRONMENT') {
+ if (!['dev', 'prod'].includes(value)) {
+ throw new Error(`
+ Environment not configured correctly. ${key} must be
+ either 'dev' or 'prod', but '${value}' was provided.
+ `);
+ }
+ }
+ return [key, value];
+ });
+
+const sanitisedEnv = Object.fromEntries(sanitisedEnvEntries) as Env;
+
+export default sanitisedEnv; \ No newline at end of file
diff --git a/api/_paypal.ts b/api/_paypal.ts
new file mode 100644
index 0000000..348c972
--- /dev/null
+++ b/api/_paypal.ts
@@ -0,0 +1,114 @@
+import type Order from './Order';
+import env from './_env';
+
+export async function paypalCreateOrder(order: Order) {
+ const accessToken = await getLazyAccessToken();
+ const url = `${PAYPAL_URL}/v2/checkout/orders`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: paypalCreateOrderRequestHeaders(accessToken),
+ body: paypalCreateOrderRequestBody(order),
+ });
+ return await handle(response);
+}
+
+export async function paypalCaptureOrder(orderId: string) {
+ const accessToken = await getLazyAccessToken();
+ const url = `${PAYPAL_URL}/v2/checkout/orders/${orderId}/capture`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: paypalCaptureOrderRequestHeaders(accessToken),
+ });
+ return await handle(response);
+}
+
+async function handle(response: Response) {
+ if (response.ok) {
+ return await response.json();
+ }
+ throw new Error('Failed to communicate with PayPal. ' + await response.text());
+}
+
+function paypalCreateOrderRequestBody(order: Order) {
+ const body = JSON.stringify({
+ intent: 'CAPTURE',
+ purchase_units: [
+ {
+ description: order.productDescription,
+ soft_descriptor: order.shortDescription,
+ amount: {
+ currency_code: 'GBP',
+ value: order.totalPrice.toFixed(2),
+ },
+ },
+ ],
+ });
+ return body;
+}
+
+function paypalCreateOrderRequestHeaders(accessToken: string) {
+ return {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ };
+}
+
+function paypalCaptureOrderRequestHeaders(accessToken: string) {
+ return {
+ Authorization: `Bearer ${accessToken}`,
+ };
+}
+
+const getLazyAccessToken = (() => {
+ let accessToken: string;
+ let timeRequested: number;
+ let timeExpires: number;
+ return async function (): Promise<string> {
+ const isNearlyExpired = timeExpires - Date.now() < 10_000;
+ if (!accessToken || isNearlyExpired) {
+ timeRequested = Date.now();
+ let secondsUntilExpires: number;
+ ({ accessToken, secondsUntilExpires } = await generateAccessToken());
+ const msUntilExpires = secondsUntilExpires * 1000;
+ timeExpires = timeRequested + msUntilExpires;
+ }
+ return accessToken;
+ };
+})();
+
+async function generateAccessToken(): Promise<AccessTokenResponse> {
+ const auth = Buffer.from(PAYPAL_CLIENT_ID + ':' + PAYPAL_SECRET).toString('base64');
+ const response = await fetch(`${PAYPAL_URL}/v1/oauth2/token`, {
+ method: 'POST',
+ body: 'grant_type=client_credentials',
+ headers: {
+ Authorization: `Basic ${auth}`,
+ },
+ });
+ const { access_token, expires_in } = await response.json();
+ if (access_token && expires_in) {
+ return {
+ accessToken: access_token,
+ secondsUntilExpires: expires_in,
+ };
+ }
+ throw new Error('Could not fetch access token from PayPal.');
+}
+
+type AccessTokenResponse = {
+ accessToken: string;
+ secondsUntilExpires: number;
+};
+
+const PAYPAL_CLIENT_ID =
+ env.ENVIRONMENT === 'prod'
+ ? env.PAYPAL_LIVE_CLIENT_ID
+ : env.PAYPAL_SANDBOX_CLIENT_ID;
+const PAYPAL_SECRET =
+ env.ENVIRONMENT === 'prod'
+ ? env.PAYPAL_LIVE_SECRET
+ : env.PAYPAL_SANDBOX_SECRET;
+const PAYPAL_URL =
+ env.ENVIRONMENT === 'prod'
+ ? 'https://api-m.paypal.com'
+ : 'https://api-m.sandbox.paypal.com' \ No newline at end of file
diff --git a/api/createPaypalOrder.ts b/api/createPaypalOrder.ts
new file mode 100644
index 0000000..1a6e810
--- /dev/null
+++ b/api/createPaypalOrder.ts
@@ -0,0 +1,28 @@
+import type { VercelRequest, VercelResponse } from '@vercel/node';
+import type Order from './Order';
+import { paypalCreateOrder } from './_paypal';
+
+export default async function handler(request: VercelRequest, response: VercelResponse) {
+ const { productDescription, shortDescription, totalPrice }: Partial<Order> = request.body;
+
+ if (request.method !== 'POST') {
+ response.status(400);
+ return;
+ }
+
+ if (!productDescription) {
+ response.status(400).send('Expected body to contain productDescription, but none provided.');
+ } else if (!shortDescription) {
+ response.status(400).send('Expected body to contain shortDescription, but none provided.');
+ } else if (!totalPrice) {
+ response.status(400).send('Expected body to contain totalPrice, but none provided.');
+ } else {
+ const paypalResponse = await paypalCreateOrder({ productDescription, shortDescription, totalPrice });
+
+ if (paypalResponse.ok) {
+ response.status(200).json({
+ orderId: paypalResponse.id,
+ });
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 7125483..cf320d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,7 @@
"": {
"dependencies": {
"astro": "^2.9.7",
+ "dotenv": "^16.3.1",
"vercel": "^31.2.2"
},
"devDependencies": {
@@ -3186,6 +3187,17 @@
"node": ">=6.0.0"
}
},
+ "node_modules/dotenv": {
+ "version": "16.3.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
+ "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/motdotla/dotenv?sponsor=1"
+ }
+ },
"node_modules/dset": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/dset/-/dset-3.1.2.tgz",
diff --git a/package.json b/package.json
index 24f5b55..a9ec215 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"astro": "^2.9.7",
+ "dotenv": "^16.3.1",
"vercel": "^31.2.2"
},
"devDependencies": {
@@ -27,7 +28,7 @@
"prettier-plugin-astro": "^0.11.0"
},
"lint-staged": {
- "frontend/**/*.{ts,cts,mts,astro,json,md,js,cjs,mjs,html,css}": [
+ "**/*.{ts,cts,mts,astro,json,md,js,cjs,mjs,html,css}": [
"eslint",
"prettier"
]
diff --git a/public/main.css b/public/main.css
index ad0f724..d428439 100644
--- a/public/main.css
+++ b/public/main.css
@@ -358,4 +358,4 @@ footer {
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
text-align: center;
-} \ No newline at end of file
+}
diff --git a/public/normalize.css b/public/normalize.css
index ccc5a05..5820d09 100644
--- a/public/normalize.css
+++ b/public/normalize.css
@@ -356,4 +356,4 @@ template {
li {
list-style: none;
-} \ No newline at end of file
+}
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index b17de4d..360acfb 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -20,8 +20,8 @@ const t = translate(locale);
<meta name="description" content={ t(site, 'description') } />
<meta name="viewport" content="width=device-width" />
<link rel="icon" href="/favicon.ico" />
- <link rel="stylesheet" href="/public/normalize.css" />
- <link rel="stylesheet" href="/public/main.css" />
+ <link rel="stylesheet" href="/normalize.css" />
+ <link rel="stylesheet" href="/main.css" />
<meta name="generator" content={Astro.generator} />
<title>{ title } | { t(site, 'title') }</title>
</head>
diff --git a/src/lib/localeStaticPaths.ts b/src/lib/localeStaticPaths.ts
new file mode 100644
index 0000000..55c3d9a
--- /dev/null
+++ b/src/lib/localeStaticPaths.ts
@@ -0,0 +1,10 @@
+import type Locale from '../types/Locale';
+
+// Defines what values to insert into the [locale] URL path parameter
+// when generating a static site
+const localeStaticPaths: { params: { locale: Locale} }[] = [
+ { params: { locale: 'sco' } },
+ { params: { locale: 'en-GB' } },
+]
+
+export default localeStaticPaths;
diff --git a/src/pages/[locale]/index.astro b/src/pages/[locale]/index.astro
index 23a270d..7552cd0 100644
--- a/src/pages/[locale]/index.astro
+++ b/src/pages/[locale]/index.astro
@@ -1,12 +1,12 @@
---
import Layout from '../../layouts/Layout.astro';
-import staticPaths from './staticPaths';
+import localeStaticPaths from '../../lib/localeStaticPaths';
import translate from '../../i18n/translate';
import site from '../../i18n/translations/site';
import home from '../../i18n/translations/pages/home';
import type Locale from '../../types/Locale';
-export async function getStaticPaths() { return staticPaths; }
+export async function getStaticPaths() { return localeStaticPaths; }
const locale = Astro.params.locale as Locale;
const t = translate(locale);