diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | api/Order.d.ts | 7 | ||||
| -rw-r--r-- | api/_env.ts | 48 | ||||
| -rw-r--r-- | api/_paypal.ts | 114 | ||||
| -rw-r--r-- | api/createPaypalOrder.ts | 28 | ||||
| -rw-r--r-- | package-lock.json | 12 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | public/main.css | 2 | ||||
| -rw-r--r-- | public/normalize.css | 2 | ||||
| -rw-r--r-- | src/layouts/Layout.astro | 4 | ||||
| -rw-r--r-- | src/lib/localeStaticPaths.ts | 10 | ||||
| -rw-r--r-- | src/pages/[locale]/index.astro | 4 |
12 files changed, 228 insertions, 7 deletions
@@ -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); |
