From 94335f04b7980f576b96512c33b88f0bdde2065f Mon Sep 17 00:00:00 2001 From: olaven Date: Tue, 13 Aug 2024 18:09:41 +0200 Subject: [PATCH] First draft of real implementation --- components/Footer.tsx | 9 ++- docs/what.md | 6 +- fresh.gen.ts | 12 ++- islands/DonationSection.tsx | 96 ++++++++++++++++++++++++ lib/storage.ts | 8 +- lib/utils.ts | 10 ++- lib/vipps/vipps.ts | 107 +++++++++++++++++++++++++++ routes/_app.tsx | 4 + routes/api/trigger-donation/vipps.ts | 11 +++ routes/donations-cancel.tsx | 19 +++++ routes/donations-error.tsx | 19 +++++ routes/donations-success.tsx | 17 +++++ routes/donations.tsx | 81 -------------------- routes/index.tsx | 6 +- 14 files changed, 305 insertions(+), 100 deletions(-) create mode 100644 islands/DonationSection.tsx create mode 100644 lib/vipps/vipps.ts create mode 100644 routes/api/trigger-donation/vipps.ts create mode 100644 routes/donations-cancel.tsx create mode 100644 routes/donations-error.tsx create mode 100644 routes/donations-success.tsx delete mode 100644 routes/donations.tsx diff --git a/components/Footer.tsx b/components/Footer.tsx index 868f185..889bc75 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -2,8 +2,13 @@ import config from "../deno.json" with { type: "json" }; export default function Footer() { return ( -
- Kildekode +
+
+ Kildekode +

+ Krets AS - 922 739 625 +

+
); } diff --git a/docs/what.md b/docs/what.md index 48f31c1..25bbaf7 100644 --- a/docs/what.md +++ b/docs/what.md @@ -7,8 +7,4 @@ egen app fremfor å bygge oppunder åpne standarder som RSS. Søk på NRK-podcasten du vil høre på. Kopier deretter URL-en under bildet. Lim denne inn i akkurat den podcastspilleren du måtte foretrekke! Se [her](https://help.omnystudio.com/en/articles/5222518-podcast-apps-that-support-add-rss-feed) -for en oversikt over hvordan det gjøres i populære podcastspillere. - -## Penger og sånt - -Denne siden har mye trafikk! Det er jeg veldig takknemlig for. Imidlertid koster det derfor en del penger å ha det kjørende. _Dersom du har anledning til det_ (og bare da!), oppfordrer jeg til å [slenge over en kaffeslant eller to](/donations) slik at NRSS fortsatt kan være gratis tilgjengelig for de som ikke har det. +for en oversikt over hvordan det gjøres i populære podcastspillere. \ No newline at end of file diff --git a/fresh.gen.ts b/fresh.gen.ts index dd4b900..6e5aed5 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -5,9 +5,13 @@ import * as $_app from "./routes/_app.tsx"; import * as $api_feeds_seriesId_ from "./routes/api/feeds/[seriesId].ts"; import * as $api_feeds_seriesId_episodeId_chapters from "./routes/api/feeds/[seriesId]/[episodeId]/chapters.ts"; -import * as $donations from "./routes/donations.tsx"; +import * as $api_trigger_donation_vipps from "./routes/api/trigger-donation/vipps.ts"; +import * as $donations_cancel from "./routes/donations-cancel.tsx"; +import * as $donations_error from "./routes/donations-error.tsx"; +import * as $donations_success from "./routes/donations-success.tsx"; import * as $index from "./routes/index.tsx"; import * as $CopyButton from "./islands/CopyButton.tsx"; +import * as $DonationSection from "./islands/DonationSection.tsx"; import { type Manifest } from "$fresh/server.ts"; const manifest = { @@ -15,11 +19,15 @@ const manifest = { "./routes/_app.tsx": $_app, "./routes/api/feeds/[seriesId].ts": $api_feeds_seriesId_, "./routes/api/feeds/[seriesId]/[episodeId]/chapters.ts": $api_feeds_seriesId_episodeId_chapters, - "./routes/donations.tsx": $donations, + "./routes/api/trigger-donation/vipps.ts": $api_trigger_donation_vipps, + "./routes/donations-cancel.tsx": $donations_cancel, + "./routes/donations-error.tsx": $donations_error, + "./routes/donations-success.tsx": $donations_success, "./routes/index.tsx": $index, }, islands: { "./islands/CopyButton.tsx": $CopyButton, + "./islands/DonationSection.tsx": $DonationSection, }, baseUrl: import.meta.url, } satisfies Manifest; diff --git a/islands/DonationSection.tsx b/islands/DonationSection.tsx new file mode 100644 index 0000000..dc06091 --- /dev/null +++ b/islands/DonationSection.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from "preact/hooks"; +import { Input } from "../components/Input.tsx"; + +export const DonationSection = function () { + const [emailInput, setEmailInput] = useState(""); + const [emailValid, setEmailValid] = useState(false); + + useEffect(() => { + const valid = emailInput.match( + // https://emailregex.com/ + // deno-lint-ignore no-control-regex + /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/, + ); + setEmailValid(!!valid); + }, [emailInput]); + + return ( + <> + +
+

+ Økonomisk Støtte +

+

+ Jeg ønsker at NRSS skal være gratis tilgjengelig for alle. Imidlertid koster det penger og tid å drifte og + vedlikeholde en nettside. Dersom du har råd til det (og bare da!) setter jeg stor pris på om du vil støtte + prosjektet med et valgfritt, månedlig beløp via Vipps. +

+ +
+
+ { + setEmailInput(e.currentTarget.value); + }} + > + + { + if (!emailValid) { + e.preventDefault(); + } + }} + > + + + + valid: {emailValid.toString()} +
+

+ Eposten brukes utelukkende som referanse dersom du ønsker å avslutte støtten. Du kommer ikke til å motta + noen eposter. +

+
+ +

+ + Du kan når som helst avslutte støtten fra{" "} + + denne siden + . + +

+ +

+ Opplever du problemer med betalingen, eller har du andre spørsmål? Ta{" "} + + kontakt på mail + . +

+
+ + ); +}; diff --git a/lib/storage.ts b/lib/storage.ts index 6b8e2db..c90d0d6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -24,20 +24,20 @@ function seriesKey(series: { id: string }) { return ["series", series.id]; } -async function read(options: { id: string }): Promise { +async function readSeries(options: { id: string }): Promise { const { id } = options; const key = seriesKey({ id }); const read = await kv.get(key); return read.value; } -async function write(series: Series): Promise { +async function writeSeries(series: Series): Promise { const key = seriesKey(series); const stored = await kv.set(key, series); return stored.ok; } export const storage = { - read, - write, + readSeries, + writeSeries, }; diff --git a/lib/utils.ts b/lib/utils.ts index 586c836..a8190ba 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -5,8 +5,14 @@ export function getHostName() { if (deploymentId) { return `https://nrss-${deploymentId}.deno.dev`; } else { - // assume env - return "http://localhost:8000"; + const proxyUrl = Deno.env.get("PROXY_URL"); + if (proxyUrl) { + console.debug(`Using proxy URL ${proxyUrl}`); + return proxyUrl; + } else { + console.debug(`Assuming localhost`); + return "http://localhost:8000"; + } } } diff --git a/lib/vipps/vipps.ts b/lib/vipps/vipps.ts new file mode 100644 index 0000000..e80b455 --- /dev/null +++ b/lib/vipps/vipps.ts @@ -0,0 +1,107 @@ +import "jsr:@std/dotenv/load"; +import { getHostName } from "../utils.ts"; +import { STATUS_CODE } from "$fresh/server.ts"; + +const config = { + clientId: Deno.env.get("VIPPS_CLIENT_ID"), + clientSecret: Deno.env.get("VIPPS_CLIENT_SECRET"), + ocpApimSubscriptionKeyPrimary: Deno.env.get("VIPPS_OCP_APIM_SUBSCRIPTION_KEY_PRIMARY"), + ocpApimSubscriptionKeySecondary: Deno.env.get("VIPPS_OCM_APIM_SUBSCRIPTION_KEY_SECONDARY"), + testMobileNumber: Deno.env.get("VIPPS_TEST_MOBILE_NUMBER"), + msn: Deno.env.get("VIPPS_MSN"), + baseUrl: Deno.env.get("VIPPS_API_BASE_URL"), +}; + +// ensure all environment variables are set +for (const [key, value] of Object.entries(config)) { + if (!value) { + throw new Error(`Missing environment variable: ${key}`); + } +} + +const standardVippsHeaders = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": config.ocpApimSubscriptionKeyPrimary, + "Merchant-Serial-Number": config.msn, + "Vipps-System-Name": "Krets AS", +} as const; + +const getAccessToken = async function () { + console.log(`Fetching ${`${config.baseUrl}/accesstoken/get`}`); + const response = await fetch(`${config.baseUrl}/accesstoken/get`, { + method: "POST", + headers: { + "client_id": config.clientId, + "client_secret": config.clientSecret, + ...standardVippsHeaders, + }, + }); + + const data = await response.json(); + return data.access_token as string; +}; + +/** + curl -X POST https://apitest.vipps.no/recurring/v3/agreements/ \ +-H "Content-Type: application/json" \ +-H "Authorization: Bearer YOUR-ACCESS-TOKEN" \ +-H "Ocp-Apim-Subscription-Key: YOUR-SUBSCRIPTION-KEY" \ +-H "Merchant-Serial-Number: YOUR-MSN" \ +-H 'Idempotency-Key: YOUR-IDEMPOTENCY-KEY' \ +-H "Vipps-System-Name: acme" \ +-H "Vipps-System-Version: 3.1.2" \ +-H "Vipps-System-Plugin-Name: acme-webshop" \ +-H "Vipps-System-Plugin-Version: 4.5.6" \ +-d '{ + "interval": { + "unit" : "WEEK", + "count": 2 + }, + "pricing": { + "amount": 1000, + "currency": "NOK" + }, + "merchantRedirectUrl": "https://example.com/redirect-url", + "merchantAgreementUrl": "https://example.com/agreement-url", + "phoneNumber": "12345678", + "productName": "Test product" +}' + */ + +export const createAgreement = async function () { + const token = await getAccessToken(); + const idempotencyKey = crypto.randomUUID(); + const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "Idempotency-Key": idempotencyKey, + ...standardVippsHeaders, + }, + body: JSON.stringify({ + "interval": { + "unit": "MONTH", + "count": 1, + }, + "pricing": { + "suggestedMaxAmount": 5000, // 50,- NOK + "currency": "NOK", + "type": "VARIABLE", + }, + "merchantRedirectUrl": `${getHostName()}/donations-success`, + "merchantAgreementUrl": `${getHostName()}/donations`, + "productName": "Månedlig støtte til NRSS", + }), + }); + + if (response.status === STATUS_CODE.Created) { + const body = response.json(); + return body as unknown as { + vippsConfirmationUrl: string; + }; + } else { + const errorMessage = `Failed to create Vipps agreement ${response.status} ${await response.text()}`; + console.error(errorMessage); + return new Error(errorMessage); + } +}; diff --git a/routes/_app.tsx b/routes/_app.tsx index 8c4c566..81afab4 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -1,4 +1,6 @@ import { PageProps } from "$fresh/server.ts"; +import Footer from "../components/Footer.tsx"; +import Header from "../components/Header.tsx"; export default function App({ Component }: PageProps) { return ( @@ -14,7 +16,9 @@ export default function App({ Component }: PageProps) { NRSS +
+
); diff --git a/routes/api/trigger-donation/vipps.ts b/routes/api/trigger-donation/vipps.ts new file mode 100644 index 0000000..0b373fc --- /dev/null +++ b/routes/api/trigger-donation/vipps.ts @@ -0,0 +1,11 @@ +import { getHostName } from "../../../lib/utils.ts"; +import { createAgreement } from "../../../lib/vipps/vipps.ts"; + +export const handler = async function (_req: Request): Promise { + const agreement = await createAgreement(); + if (agreement instanceof Error) { + return Response.redirect(`${getHostName()}/donations-error`); + } + + return Response.redirect(agreement.vippsConfirmationUrl); +}; diff --git a/routes/donations-cancel.tsx b/routes/donations-cancel.tsx new file mode 100644 index 0000000..dfc7760 --- /dev/null +++ b/routes/donations-cancel.tsx @@ -0,0 +1,19 @@ +import { Input } from "../components/Input.tsx"; + +export default function () { + return ( +
+

+ Avslutt støtte til NRSS +

+ + +
+ ); +} diff --git a/routes/donations-error.tsx b/routes/donations-error.tsx new file mode 100644 index 0000000..c50fbfd --- /dev/null +++ b/routes/donations-error.tsx @@ -0,0 +1,19 @@ +export default function () { + return ( +
+

+ 💥🔧🚨😱 +

+

+ Auda. Noe gikk galt. Det er flaut. +

+

+ Forteller du meg om feilen{" "} + + på mail + {" "} + blir jeg veldig glad. +

+
+ ); +} diff --git a/routes/donations-success.tsx b/routes/donations-success.tsx new file mode 100644 index 0000000..a85e9da --- /dev/null +++ b/routes/donations-success.tsx @@ -0,0 +1,17 @@ +export default function () { + return ( +
+

+ 🙏🌟😊💖 +

+

+ Tusen takk! Jeg er veldig takknemlig for all støtte. +

+

+ + Gå tilbake til forsiden + +

+
+ ); +} diff --git a/routes/donations.tsx b/routes/donations.tsx deleted file mode 100644 index 08c88b4..0000000 --- a/routes/donations.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { ButtonLink } from "../components/Button.tsx"; -import { Button } from "../components/Button.tsx"; -import Header from "../components/Header.tsx"; -import { Input } from "../components/Input.tsx"; - -export default () => { - return ( -
-

- DEMOSIDE - FUNGERER IKKE -

-
- -
-

- Økonomisk støtte til NRSS -

-

- Jeg ønsker at NRSS skal være gratis tilgjengelig for alle. Imidlertid koster det penger og tid å drifte og - vedlikeholde en nettside. Dersom du har råd til det (og bare da!) setter jeg stor pris på om du vil støtte - prosjektet med et valgfritt, månedlig beløp via Vipps. -

- -
- - -
- - -
-

- Epost blir utelukkende brukt til å knytte deg til en donasjon, slik at du kan kansellere den senere. -

-
- -

- Du kan når som helst avslutte abonnementet fra denne siden. -

-

- Opplever du problemer med betalingen, eller har du andre spørsmål? Ta{" "} - - kontakt på mail - . -

-
-
- Krets AS - 922 739 625 -
-
- ); -}; diff --git a/routes/index.tsx b/routes/index.tsx index d10efbb..2872ab4 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -1,11 +1,10 @@ import { Head } from "$fresh/runtime.ts"; import { Handlers, PageProps } from "$fresh/server.ts"; -import Footer from "../components/Footer.tsx"; -import Header from "../components/Header.tsx"; import Search from "../components/Search.tsx"; import SeriesCard from "../components/SeriesCard.tsx"; import { CSS, render } from "$gfm"; import { nrkRadio, NrkSearchResultList } from "../lib/nrk/nrk.ts"; +import { DonationSection } from "../islands/DonationSection.tsx"; type Props = { query: string | null; @@ -43,14 +42,13 @@ export default function Home({ data, url }: PageProps) { />
-
-
);