From c7e83785225e7f053454f5dc80ba70f32c426c92 Mon Sep 17 00:00:00 2001 From: olaven Date: Mon, 29 Jul 2024 19:37:41 +0200 Subject: [PATCH 01/18] Vipps Demo Page Implementing a demo of how Vipps-integration could look in order to get approved for integration. --- components/Input.tsx | 12 +++++++ components/Search.tsx | 5 +-- docs/what.md | 2 +- fresh.gen.ts | 2 ++ routes/donations.tsx | 76 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 components/Input.tsx create mode 100644 routes/donations.tsx diff --git a/components/Input.tsx b/components/Input.tsx new file mode 100644 index 0000000..ea652c7 --- /dev/null +++ b/components/Input.tsx @@ -0,0 +1,12 @@ +import { JSX } from "preact"; + +export const Input = ( + props: JSX.HTMLAttributes, +) => { + return ( + + ); +}; diff --git a/components/Search.tsx b/components/Search.tsx index da0863a..4db4afd 100644 --- a/components/Search.tsx +++ b/components/Search.tsx @@ -1,11 +1,12 @@ +import { Input } from "./Input.tsx"; + export default function Search(props: { defaultValue: string | null }) { return (
- { + 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 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 + . +

+
+
+ ); +}; -- 2.40.1 From b86e9d6a14528c90ce5d41aacd5290058c809a09 Mon Sep 17 00:00:00 2001 From: olaven Date: Mon, 29 Jul 2024 19:43:30 +0200 Subject: [PATCH 02/18] Added orgnr --- routes/donations.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/routes/donations.tsx b/routes/donations.tsx index b4f54a2..08c88b4 100644 --- a/routes/donations.tsx +++ b/routes/donations.tsx @@ -24,7 +24,7 @@ export default () => {

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 beløp via Vipps. + prosjektet med et valgfritt, månedlig beløp via Vipps.

{ .

+
+ Krets AS - 922 739 625 +
); }; -- 2.40.1 From 94335f04b7980f576b96512c33b88f0bdde2065f Mon Sep 17 00:00:00 2001 From: olaven Date: Tue, 13 Aug 2024 18:09:41 +0200 Subject: [PATCH 03/18] 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) { />
-
-
+
); -- 2.40.1 From b3ff744f4da9b500c209357819c37f0b51a14761 Mon Sep 17 00:00:00 2001 From: olaven Date: Tue, 13 Aug 2024 18:22:13 +0200 Subject: [PATCH 04/18] Rewrote storage to support more entities --- lib/caching.ts | 6 +++--- lib/storage.test.ts | 8 ++++---- lib/storage.ts | 41 +++++++++++++++++++++++++++++------------ 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/lib/caching.ts b/lib/caching.ts index 3ee20e1..b51afa0 100644 --- a/lib/caching.ts +++ b/lib/caching.ts @@ -10,7 +10,7 @@ async function initialFetch(options: { id: string }): Promise { if (!series) { return null; } - const stored = storage.write(series); + const stored = storage.writeSeries(series); if (!stored) { console.error(`Failed to store series ${options.id}`); return null; @@ -50,7 +50,7 @@ async function updateFetch(existingSeries: Series): Promise { - const seriesFromStorage = await storage.read(options); + const seriesFromStorage = await storage.readSeries(options); /** * We don't have the feed in storage, diff --git a/lib/storage.test.ts b/lib/storage.test.ts index 602374f..214aee0 100644 --- a/lib/storage.test.ts +++ b/lib/storage.test.ts @@ -4,18 +4,18 @@ import { testUtils } from "./test-utils.ts"; Deno.test("can store a series", async () => { const series = testUtils.generateSeries(); - await storage.write(series); + await storage.writeSeries(series); - const readSeries = await storage.read(series); + const readSeries = await storage.readSeries(series); assertEquals(readSeries, series); }); Deno.test("can retrieve a series", async () => { const series = testUtils.generateSeries(); - await storage.write(series); + await storage.writeSeries(series); - const readSeries = await storage.read(series); + const readSeries = await storage.readSeries(series); assertEquals(readSeries, series); }); diff --git a/lib/storage.ts b/lib/storage.ts index c90d0d6..b9fe4d3 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -18,26 +18,43 @@ export type Series = { episodes: Episode[]; }; +export type VippsAgreement = { + id: string; + agreementId: string; + userEmail: string; + createdAt: Date; + revokedAt: Date | null; +}; + const kv = await Deno.openKv(); -function seriesKey(series: { id: string }) { - return ["series", series.id]; +type Identifiable = { id: string }; +type Collection = "series" | "vipps-agreements"; + +function read(collection: Collection) { + return async function (series: Identifiable) { + const key = [collection, series.id]; + const read = await kv.get(key); + return read.value as T | null; + }; } -async function readSeries(options: { id: string }): Promise { - const { id } = options; - const key = seriesKey({ id }); - const read = await kv.get(key); - return read.value; +function write(collection: Collection) { + return async function (entity: T) { + const key = [collection, entity.id]; + const stored = await kv.set(key, entity); + return stored.ok; + }; } -async function writeSeries(series: Series): Promise { - const key = seriesKey(series); - const stored = await kv.set(key, series); - return stored.ok; -} +const readSeries = read("series"); +const writeSeries = write("series"); +const readVippsAgreement = read("vipps-agreements"); +const writeVippsAgreement = write("vipps-agreements"); export const storage = { readSeries, writeSeries, + readVippsAgreement, + writeVippsAgreement, }; -- 2.40.1 From 20eee29adc0446f75c54f291e61cca520ffb666f Mon Sep 17 00:00:00 2001 From: olaven Date: Tue, 13 Aug 2024 19:18:09 +0200 Subject: [PATCH 05/18] WIP on agreement success path --- islands/DonationSection.tsx | 9 ++--- lib/utils.ts | 10 ++++++ lib/vipps/vipps.ts | 50 +++++++++++----------------- routes/api/trigger-donation/vipps.ts | 23 ++++++++++--- routes/donations-success.tsx | 29 +++++++++++++++- 5 files changed, 80 insertions(+), 41 deletions(-) diff --git a/islands/DonationSection.tsx b/islands/DonationSection.tsx index dc06091..44003a0 100644 --- a/islands/DonationSection.tsx +++ b/islands/DonationSection.tsx @@ -1,16 +1,13 @@ import { useEffect, useState } from "preact/hooks"; import { Input } from "../components/Input.tsx"; +import { validateEmail } from "../lib/utils.ts"; 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])+)\])/, - ); + const valid = validateEmail(emailInput); setEmailValid(!!valid); }, [emailInput]); @@ -45,7 +42,7 @@ export const DonationSection = function () { { if (!emailValid) { e.preventDefault(); diff --git a/lib/utils.ts b/lib/utils.ts index a8190ba..625bb10 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -36,3 +36,13 @@ function response(body: string, status: number, type: "json" | "xml") { }, }); } + +export function validateEmail(input: string) { + input.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])+)\])/, + ); + + return !!input; +} diff --git a/lib/vipps/vipps.ts b/lib/vipps/vipps.ts index e80b455..9f64c31 100644 --- a/lib/vipps/vipps.ts +++ b/lib/vipps/vipps.ts @@ -41,41 +41,13 @@ const getAccessToken = async function () { 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 () { +export const createAgreement = async function (email: string) { 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, + "Idempotency-Key": email, ...standardVippsHeaders, }, body: JSON.stringify({ @@ -105,3 +77,21 @@ export const createAgreement = async function () { return new Error(errorMessage); } }; + +export const getAgreement = async function (agreementId: string) { + const token = await getAccessToken(); + const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { + headers: { + authorization: `Bearer ${token}`, + ...standardVippsHeaders, + }, + }); + + if (response.status === STATUS_CODE.OK) { + return response.json(); + } else { + const errorMessage = `Failed to get Vipps agreement ${response.status} ${await response.text()}`; + console.error(errorMessage); + return new Error(errorMessage); + } +}; diff --git a/routes/api/trigger-donation/vipps.ts b/routes/api/trigger-donation/vipps.ts index 0b373fc..6bce44a 100644 --- a/routes/api/trigger-donation/vipps.ts +++ b/routes/api/trigger-donation/vipps.ts @@ -1,10 +1,25 @@ -import { getHostName } from "../../../lib/utils.ts"; +import { getHostName, validateEmail } from "../../../lib/utils.ts"; import { createAgreement } from "../../../lib/vipps/vipps.ts"; -export const handler = async function (_req: Request): Promise { - const agreement = await createAgreement(); +export const handler = async function (req: Request): Promise { + const email = new URLSearchParams(req.url.split("?")[1] || "").get("email"); + const errorPage = `${getHostName()}/donations-error`; + + if (!email) { + console.error("Missing email in query params", req.url); + return Response.redirect(errorPage); + } + + const valid = validateEmail(email); + if (!valid) { + console.error("Invalid email server side", email); + return Response.redirect(errorPage); + } + + const agreement = await createAgreement(email); if (agreement instanceof Error) { - return Response.redirect(`${getHostName()}/donations-error`); + console.error("Failed to create Vipps agreement", agreement); + return Response.redirect(errorPage); } return Response.redirect(agreement.vippsConfirmationUrl); diff --git a/routes/donations-success.tsx b/routes/donations-success.tsx index a85e9da..5ad5eef 100644 --- a/routes/donations-success.tsx +++ b/routes/donations-success.tsx @@ -1,4 +1,31 @@ -export default function () { +import { PageProps } from "$fresh/server.ts"; +import { getAgreement } from "../lib/vipps/vipps.ts"; + +export const handler: Handlers = { + async GET(request, ctx) { + // TODO: somehow get the agreement ID + // TODO: implement cancel page + + const agreementId = "123"; + const agreement = await getAgreement(agreementId); + if (agreement instanceof Error) { + console.error("Failed to get Vipps agreement", agreement); + return ctx.redirect("/donations-error"); + } + + return ctx.render({ agreement }); + }, +}; + +export default function ({ data }: PageProps<{ + // TODO: confirm interface when you know it + agreement: { + status: string; + id: string; + }; + + status: string; +}>) { return (

-- 2.40.1 From 5b3f804391bb314f8ae8d3eec5bd2029795bd2b6 Mon Sep 17 00:00:00 2001 From: olaven Date: Sat, 24 Aug 2024 16:47:56 +0200 Subject: [PATCH 06/18] Cancellation implemented --- lib/rss.ts | 4 +- lib/storage.ts | 4 +- lib/utils.ts | 16 +++--- lib/vipps/vipps.ts | 33 ++++++++++-- routes/api/trigger-donation/vipps.ts | 15 +++++- routes/donations-cancel.tsx | 80 ++++++++++++++++++++++++++-- routes/donations-success.tsx | 39 +++++++++----- 7 files changed, 155 insertions(+), 36 deletions(-) diff --git a/lib/rss.ts b/lib/rss.ts index e02694e..d3a301b 100644 --- a/lib/rss.ts +++ b/lib/rss.ts @@ -1,5 +1,5 @@ import { declaration, serialize, Tag, tag } from "serialize-xml"; -import { getHostName } from "./utils.ts"; +import { getHostUrl } from "./utils.ts"; import { Episode, Series } from "./storage.ts"; function assembleFeed(series: Series): string { @@ -71,7 +71,7 @@ function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag { tag("podcast:chapters", "", [ [ "url", - `${getHostName()}/api/feeds/${seriesId}/${episode.id}/chapters`, + `${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`, ], ["type", "application/json+chapters"], ]), diff --git a/lib/storage.ts b/lib/storage.ts index b9fe4d3..55c6e18 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -19,10 +19,12 @@ export type Series = { }; export type VippsAgreement = { + // the user identifier / email id: string; + // the agreement id from Vipps agreementId: string; - userEmail: string; createdAt: Date; + validAt: Date | null; revokedAt: Date | null; }; diff --git a/lib/utils.ts b/lib/utils.ts index 625bb10..370f50e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,18 +1,16 @@ import { STATUS_CODE } from "$fresh/server.ts"; -export function getHostName() { +export function getHostUrl() { const deploymentId = Deno.env.get("DENO_DEPLOYMENT_ID"); + const tunnelUrl = Deno.env.get("TUNNEL_URL"); if (deploymentId) { return `https://nrss-${deploymentId}.deno.dev`; + } else if (tunnelUrl) { + console.debug(`Using tunnel URL: ${tunnelUrl}`); + return tunnelUrl; } else { - 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"; - } + console.debug(`Assuming localhost`); + return "http://localhost:8000"; } } diff --git a/lib/vipps/vipps.ts b/lib/vipps/vipps.ts index 9f64c31..7caefd1 100644 --- a/lib/vipps/vipps.ts +++ b/lib/vipps/vipps.ts @@ -1,5 +1,5 @@ import "jsr:@std/dotenv/load"; -import { getHostName } from "../utils.ts"; +import { getHostUrl } from "../utils.ts"; import { STATUS_CODE } from "$fresh/server.ts"; const config = { @@ -47,7 +47,7 @@ export const createAgreement = async function (email: string) { method: "POST", headers: { authorization: `Bearer ${token}`, - "Idempotency-Key": email, + "Idempotency-Key": `${email}-${Date.now()}`, ...standardVippsHeaders, }, body: JSON.stringify({ @@ -60,8 +60,9 @@ export const createAgreement = async function (email: string) { "currency": "NOK", "type": "VARIABLE", }, - "merchantRedirectUrl": `${getHostName()}/donations-success`, - "merchantAgreementUrl": `${getHostName()}/donations`, + // email is used to identify the user in the success page + "merchantRedirectUrl": `${getHostUrl()}/donations-success?email=${email}`, + "merchantAgreementUrl": `${getHostUrl()}`, "productName": "Månedlig støtte til NRSS", }), }); @@ -70,6 +71,7 @@ export const createAgreement = async function (email: string) { const body = response.json(); return body as unknown as { vippsConfirmationUrl: string; + agreementId: string; }; } else { const errorMessage = `Failed to create Vipps agreement ${response.status} ${await response.text()}`; @@ -95,3 +97,26 @@ export const getAgreement = async function (agreementId: string) { return new Error(errorMessage); } }; + +export const cancelAgreement = async function (agreementId: string) { + const token = await getAccessToken(); + const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { + method: "PATCH", + headers: { + authorization: `Bearer ${token}`, + "Idempotency-Key": `${agreementId}-${Date.now()}`, + ...standardVippsHeaders, + }, + body: JSON.stringify({ + "status": "STOPPED", + }), + }); + + if (response.status === STATUS_CODE.NoContent) { + return true; + } else { + const errorMessage = `Failed to cancel Vipps agreement ${response.status} ${await response.text()}`; + console.error(errorMessage); + return new Error(errorMessage); + } +}; diff --git a/routes/api/trigger-donation/vipps.ts b/routes/api/trigger-donation/vipps.ts index 6bce44a..4b64691 100644 --- a/routes/api/trigger-donation/vipps.ts +++ b/routes/api/trigger-donation/vipps.ts @@ -1,9 +1,10 @@ -import { getHostName, validateEmail } from "../../../lib/utils.ts"; +import { getHostUrl, validateEmail } from "../../../lib/utils.ts"; +import { storage } from "../../../lib/storage.ts"; import { createAgreement } from "../../../lib/vipps/vipps.ts"; export const handler = async function (req: Request): Promise { const email = new URLSearchParams(req.url.split("?")[1] || "").get("email"); - const errorPage = `${getHostName()}/donations-error`; + const errorPage = `${getHostUrl()}/donations-error`; if (!email) { console.error("Missing email in query params", req.url); @@ -22,5 +23,15 @@ export const handler = async function (req: Request): Promise { return Response.redirect(errorPage); } + // associate the agreement with the user here + // so it can be updated in the success page + await storage.writeVippsAgreement({ + id: email, + agreementId: agreement.agreementId, + createdAt: new Date(), + validAt: null, + revokedAt: null, + }); + return Response.redirect(agreement.vippsConfirmationUrl); }; diff --git a/routes/donations-cancel.tsx b/routes/donations-cancel.tsx index dfc7760..4debd34 100644 --- a/routes/donations-cancel.tsx +++ b/routes/donations-cancel.tsx @@ -1,6 +1,50 @@ +import { Handlers, PageProps } from "$fresh/server.ts"; +import { Button } from "../components/Button.tsx"; import { Input } from "../components/Input.tsx"; +import { storage } from "../lib/storage.ts"; +import * as vipps from "../lib/vipps/vipps.ts"; +import { validateEmail } from "../lib/utils.ts"; -export default function () { +type Props = { + email: string; + error: string | null; + cancelled: boolean; +}; + +export const handler: Handlers = { + async GET(request, ctx) { + const url = new URL(request.url); + const email = url.searchParams.get("email"); + + if (!email) { + return ctx.render({ email: null, error: null, cancelled: false }); + } + + if (!validateEmail(email)) { + console.error("Ugyldig e-post server-side", email); + return ctx.render({ email: null, error: "Ugyldig e-post", cancelled: false }); + } + + const agreement = await storage.readVippsAgreement({ id: email }); + if (!agreement) { + console.error("Mangler avtale for e-post", email); + return ctx.render({ email: null, error: `Ingen abonnement funnet for "${email}"`, cancelled: false }); + } + + const vippsResponse = await vipps.cancelAgreement(agreement.agreementId); + if (vippsResponse instanceof Error) { + return ctx.render({ email, error: "Kunne ikke kansellere Vipps-abonnement", cancelled: false }); + } + await storage.writeVippsAgreement({ + ...agreement, + revokedAt: new Date(), + }); + + return ctx.render({ email, error: null, cancelled: true }); + }, +}; + +export default function ({ data }: PageProps) { return (
Avslutt støtte til NRSS

- - + {data.error && ( +

+ En feil oppsto: "{data.error}". Ta gjerne{" "} + + kontakt på mail + . +

+ )} + {data.cancelled && ( +

+ Støtten er nå avsluttet. Tusen takk for at du har støttet NRSS! +

+ )} + {!data.cancelled && ( +
+ + + +
+ )}
); } diff --git a/routes/donations-success.tsx b/routes/donations-success.tsx index 5ad5eef..4d3b56b 100644 --- a/routes/donations-success.tsx +++ b/routes/donations-success.tsx @@ -1,24 +1,37 @@ -import { PageProps } from "$fresh/server.ts"; -import { getAgreement } from "../lib/vipps/vipps.ts"; +import { Handlers, PageProps } from "$fresh/server.ts"; +import { storage } from "../lib/storage.ts"; +import { validateEmail } from "../lib/utils.ts"; -export const handler: Handlers = { +export const handler: Handlers = { async GET(request, ctx) { - // TODO: somehow get the agreement ID - // TODO: implement cancel page - - const agreementId = "123"; - const agreement = await getAgreement(agreementId); - if (agreement instanceof Error) { - console.error("Failed to get Vipps agreement", agreement); - return ctx.redirect("/donations-error"); + const url = new URL(request.url); + const email = url.searchParams.get("email"); + if (!email) { + console.error("Missing email in query params", request.url); + return Response.redirect("/donations-error"); } - return ctx.render({ agreement }); + if (!validateEmail(email)) { + console.error("Invalid email server side", email); + return Response.redirect("/donations-error"); + } + + const agreement = await storage.readVippsAgreement({ id: email }); + if (!agreement) { + console.error("Missing agreement for email", email); + return Response.redirect("/donations-error"); + } + + const validatedAgreement = await storage.writeVippsAgreement({ + ...agreement, + validAt: new Date(), + }); + + return ctx.render({ agreement: validatedAgreement }); }, }; export default function ({ data }: PageProps<{ - // TODO: confirm interface when you know it agreement: { status: string; id: string; -- 2.40.1 From d055a77057f72ebcfb64f999abf48f7f85e5979b Mon Sep 17 00:00:00 2001 From: olaven Date: Sat, 24 Aug 2024 16:56:04 +0200 Subject: [PATCH 07/18] Add promo info to episode descriptions --- lib/rss.test.ts | 8 ++++++++ lib/rss.ts | 14 +++++++++++++- routes/donations-cancel.tsx | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/rss.test.ts b/lib/rss.test.ts index f3d0532..ff3b5d0 100644 --- a/lib/rss.test.ts +++ b/lib/rss.test.ts @@ -25,3 +25,11 @@ Deno.test("generated rss contains all episode titles", () => { assertEquals(feed.includes(episode.title), true); }); }); + +Deno.test("generated rss contains promo with link to donations page", () => { + const series = testUtils.generateSeries(); + const feed = rss.assembleFeed(series); + + console.log(feed); + feed.includes("Vurder å støtte utviklingen via Vipps med valgfritt beløp"); +}); diff --git a/lib/rss.ts b/lib/rss.ts index d3a301b..076b3a3 100644 --- a/lib/rss.ts +++ b/lib/rss.ts @@ -57,8 +57,20 @@ function assembleFeed(series: Series): string { ); } +function descriptionWithDonationPromotion(description: string): string { + const promotion = + `Takk for at du bruker NRSS 🙏🌟 Vurder å støtte utviklingen via Vipps med valgfritt beløp. Se mer på ${getHostUrl()}.`; + + return ` + --------------------------------\n + ${promotion} \n + --------------------------------\n + ${description} + `; +} + function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag { - const description = episode.subtitle || ""; + const description = descriptionWithDonationPromotion(episode.subtitle || ""); return tag("item", [ tag("title", episode.title), diff --git a/routes/donations-cancel.tsx b/routes/donations-cancel.tsx index 4debd34..5bb8009 100644 --- a/routes/donations-cancel.tsx +++ b/routes/donations-cancel.tsx @@ -64,7 +64,7 @@ export default function ({ data }: PageProps) { )} {data.cancelled && (

- Støtten er nå avsluttet. Tusen takk for at du har støttet NRSS! + Støtten er nå avsluttet. Tusen takk for at du har støttet NRSS 🙏🌟

)} {!data.cancelled && ( -- 2.40.1 From 7a920ae2d005c5c95666431e6e08ad6ff610b761 Mon Sep 17 00:00:00 2001 From: olaven Date: Sat, 24 Aug 2024 17:41:27 +0200 Subject: [PATCH 08/18] Formatter --- docs/what.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/what.md b/docs/what.md index 25bbaf7..c1a3365 100644 --- a/docs/what.md +++ b/docs/what.md @@ -7,4 +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. \ No newline at end of file +for en oversikt over hvordan det gjøres i populære podcastspillere. -- 2.40.1 From 127758dc44c8d393e2c3d167d625c98a283effa5 Mon Sep 17 00:00:00 2001 From: olaven Date: Sat, 24 Aug 2024 17:46:00 +0200 Subject: [PATCH 09/18] Make build --- islands/DonationSection.tsx | 2 ++ lib/vipps/vipps.ts | 5 +++++ routes/donations-cancel.tsx | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/islands/DonationSection.tsx b/islands/DonationSection.tsx index 44003a0..1777a16 100644 --- a/islands/DonationSection.tsx +++ b/islands/DonationSection.tsx @@ -49,6 +49,7 @@ export const DonationSection = function () { } }} > + {/* @ts-ignore */} + {/* @ts-ignore */} valid: {emailValid.toString()} diff --git a/lib/vipps/vipps.ts b/lib/vipps/vipps.ts index 7caefd1..2c4d42f 100644 --- a/lib/vipps/vipps.ts +++ b/lib/vipps/vipps.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file ban-ts-comment import "jsr:@std/dotenv/load"; import { getHostUrl } from "../utils.ts"; import { STATUS_CODE } from "$fresh/server.ts"; @@ -30,6 +31,7 @@ const getAccessToken = async function () { console.log(`Fetching ${`${config.baseUrl}/accesstoken/get`}`); const response = await fetch(`${config.baseUrl}/accesstoken/get`, { method: "POST", + // @ts-ignore headers: { "client_id": config.clientId, "client_secret": config.clientSecret, @@ -45,6 +47,7 @@ export const createAgreement = async function (email: string) { const token = await getAccessToken(); const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/`, { method: "POST", + // @ts-ignore headers: { authorization: `Bearer ${token}`, "Idempotency-Key": `${email}-${Date.now()}`, @@ -83,6 +86,7 @@ export const createAgreement = async function (email: string) { export const getAgreement = async function (agreementId: string) { const token = await getAccessToken(); const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { + // @ts-ignore headers: { authorization: `Bearer ${token}`, ...standardVippsHeaders, @@ -102,6 +106,7 @@ export const cancelAgreement = async function (agreementId: string) { const token = await getAccessToken(); const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { method: "PATCH", + // @ts-ignore headers: { authorization: `Bearer ${token}`, "Idempotency-Key": `${agreementId}-${Date.now()}`, diff --git a/routes/donations-cancel.tsx b/routes/donations-cancel.tsx index 5bb8009..d8c46aa 100644 --- a/routes/donations-cancel.tsx +++ b/routes/donations-cancel.tsx @@ -6,7 +6,7 @@ import * as vipps from "../lib/vipps/vipps.ts"; import { validateEmail } from "../lib/utils.ts"; type Props = { - email: string; + email: string | null; error: string | null; cancelled: boolean; }; -- 2.40.1 From c20efc939abf336215dc06d01ae678d4f49a40c7 Mon Sep 17 00:00:00 2001 From: olaven Date: Sat, 24 Aug 2024 17:53:46 +0200 Subject: [PATCH 10/18] Hide email validation from ui --- islands/DonationSection.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/islands/DonationSection.tsx b/islands/DonationSection.tsx index 1777a16..e0ecca2 100644 --- a/islands/DonationSection.tsx +++ b/islands/DonationSection.tsx @@ -64,7 +64,6 @@ export const DonationSection = function () { {/* @ts-ignore */} - valid: {emailValid.toString()}

Date: Sat, 24 Aug 2024 18:00:39 +0200 Subject: [PATCH 11/18] Hinder duplicate agreements on same email --- routes/api/trigger-donation/vipps.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/routes/api/trigger-donation/vipps.ts b/routes/api/trigger-donation/vipps.ts index 4b64691..7749504 100644 --- a/routes/api/trigger-donation/vipps.ts +++ b/routes/api/trigger-donation/vipps.ts @@ -17,6 +17,12 @@ export const handler = async function (req: Request): Promise { return Response.redirect(errorPage); } + const existingAgreement = await storage.readVippsAgreement({ id: email }); + if (existingAgreement) { + console.error("Agreement already exists", email); + return Response.redirect(errorPage); + } + const agreement = await createAgreement(email); if (agreement instanceof Error) { console.error("Failed to create Vipps agreement", agreement); -- 2.40.1 From af99d4ec5afbd1457c92c5efeb22ee7fae89aa29 Mon Sep 17 00:00:00 2001 From: olaven Date: Sat, 24 Aug 2024 18:28:44 +0200 Subject: [PATCH 12/18] Improve UI a bit --- components/Button.tsx | 2 +- islands/DonationSection.tsx | 20 +++++++++----------- lib/rss.test.ts | 2 +- lib/rss.ts | 2 +- lib/vipps/vipps.ts | 9 +++++++-- routes/api/trigger-donation/vipps.ts | 2 +- routes/donations-cancel.tsx | 13 +++++++------ 7 files changed, 27 insertions(+), 23 deletions(-) diff --git a/components/Button.tsx b/components/Button.tsx index 28d4ad6..6c5aeb1 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -4,7 +4,7 @@ type ButtonProps = { children: ComponentChildren; } & JSX.HTMLAttributes; -const className = "p-2 border(gray-100 2) hover:bg-gray-200 bg-blue-200 w-max font-medium"; +const className = "p-2 border(gray-100 2) hover:bg-gray-200 bg-blue-200 font-medium"; export function Button(props: ButtonProps) { const { children, className: additionalClass = "", ...buttonProps } = props; diff --git a/islands/DonationSection.tsx b/islands/DonationSection.tsx index e0ecca2..84dec1d 100644 --- a/islands/DonationSection.tsx +++ b/islands/DonationSection.tsx @@ -26,11 +26,11 @@ export const DonationSection = function () {

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. + prosjektet med et lite, månedlig beløp via Vipps.

-
+ { if (!emailValid) { e.preventDefault(); @@ -70,18 +71,15 @@ export const DonationSection = function () { > 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 + . +

-

- - 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{" "} diff --git a/lib/rss.test.ts b/lib/rss.test.ts index ff3b5d0..fead2fb 100644 --- a/lib/rss.test.ts +++ b/lib/rss.test.ts @@ -31,5 +31,5 @@ Deno.test("generated rss contains promo with link to donations page", () => { const feed = rss.assembleFeed(series); console.log(feed); - feed.includes("Vurder å støtte utviklingen via Vipps med valgfritt beløp"); + feed.includes("Vurder å støtte utviklingen via Vipps"); }); diff --git a/lib/rss.ts b/lib/rss.ts index 076b3a3..a678ca8 100644 --- a/lib/rss.ts +++ b/lib/rss.ts @@ -59,7 +59,7 @@ function assembleFeed(series: Series): string { function descriptionWithDonationPromotion(description: string): string { const promotion = - `Takk for at du bruker NRSS 🙏🌟 Vurder å støtte utviklingen via Vipps med valgfritt beløp. Se mer på ${getHostUrl()}.`; + `Takk for at du bruker NRSS 🙏🌟 Vurder å støtte utviklingen via Vipps med omtrent det samme som prisen på en kaffekopp. Se mer på ${getHostUrl()}.`; return ` --------------------------------\n diff --git a/lib/vipps/vipps.ts b/lib/vipps/vipps.ts index 2c4d42f..59db725 100644 --- a/lib/vipps/vipps.ts +++ b/lib/vipps/vipps.ts @@ -45,6 +45,7 @@ const getAccessToken = async function () { export const createAgreement = async function (email: string) { const token = await getAccessToken(); + const amount = 5000; // 50 NOK const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/`, { method: "POST", // @ts-ignore @@ -58,10 +59,14 @@ export const createAgreement = async function (email: string) { "unit": "MONTH", "count": 1, }, + "initialCharge": { + "amount": amount, + "description": "Initial charge", + "transactionType": "DIRECT_CAPTURE", + }, "pricing": { - "suggestedMaxAmount": 5000, // 50,- NOK + "amount": amount, "currency": "NOK", - "type": "VARIABLE", }, // email is used to identify the user in the success page "merchantRedirectUrl": `${getHostUrl()}/donations-success?email=${email}`, diff --git a/routes/api/trigger-donation/vipps.ts b/routes/api/trigger-donation/vipps.ts index 7749504..76b44e4 100644 --- a/routes/api/trigger-donation/vipps.ts +++ b/routes/api/trigger-donation/vipps.ts @@ -18,7 +18,7 @@ export const handler = async function (req: Request): Promise { } const existingAgreement = await storage.readVippsAgreement({ id: email }); - if (existingAgreement) { + if (existingAgreement && existingAgreement.revokedAt === null) { console.error("Agreement already exists", email); return Response.redirect(errorPage); } diff --git a/routes/donations-cancel.tsx b/routes/donations-cancel.tsx index d8c46aa..9fea969 100644 --- a/routes/donations-cancel.tsx +++ b/routes/donations-cancel.tsx @@ -50,7 +50,7 @@ export default function ({ data }: PageProps) { className={"p-4 mx-auto max-w-screen-md text-center"} >

Avslutt støtte til NRSS

@@ -63,21 +63,22 @@ export default function ({ data }: PageProps) {

)} {data.cancelled && ( -

- Støtten er nå avsluttet. Tusen takk for at du har støttet NRSS 🙏🌟 +

+ Støtten er nå avsluttet.
+ Tusen takk for at du har støttet NRSS 🙏

)} {!data.cancelled && ( - + - + />