Vipps Donations (#33)
This commit is contained in:
parent
ee6339642f
commit
c0088e067f
|
|
@ -4,7 +4,7 @@ type ButtonProps = {
|
|||
children: ComponentChildren;
|
||||
} & JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,13 @@ import config from "../deno.json" with { type: "json" };
|
|||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<div className="mt-4 border-t-2 pt-4">
|
||||
<a class="text-blue-600 hover:underline" href={config.source}>Kildekode</a>
|
||||
<div className={"p-4 mx-auto max-w-screen-md"}>
|
||||
<div className="mt-4 border-t-2 pt-4">
|
||||
<a class="text-blue-600 hover:underline" href={config.source}>Kildekode</a>
|
||||
<p>
|
||||
Krets AS - 922 739 625
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { JSX } from "preact";
|
||||
|
||||
export const Input = (
|
||||
props: JSX.HTMLAttributes<HTMLInputElement>,
|
||||
) => {
|
||||
return (
|
||||
<input
|
||||
className="border-2 rounded-md px-2"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { Input } from "./Input.tsx";
|
||||
|
||||
export default function Search(props: { defaultValue: string | null }) {
|
||||
return (
|
||||
<form className="flex flex-col w-full lg:w-2/3 mx-auto my-4">
|
||||
<label className="sr-only" htmlFor="query">Program</label>
|
||||
<input
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="NRK-podcast"
|
||||
className="border-2 rounded-md px-2"
|
||||
name="query"
|
||||
id="query"
|
||||
defaultValue={props.defaultValue ?? ""}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,3 @@ 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](https://github.com/sponsors/olaven) slik at NRSS fortsatt kan være gratis tilgjengelig for de som ikke har det.
|
||||
|
|
|
|||
10
fresh.gen.ts
10
fresh.gen.ts
|
|
@ -5,8 +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 $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 = {
|
||||
|
|
@ -14,10 +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/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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
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<string>("");
|
||||
const [emailValid, setEmailValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const valid = validateEmail(emailInput);
|
||||
setEmailValid(!!valid);
|
||||
}, [emailInput]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
async
|
||||
type="text/javascript"
|
||||
src="https://checkout.vipps.no/checkout-button/v1/vipps-checkout-button.js"
|
||||
>
|
||||
</script>
|
||||
<div className="max-w mx-auto py-4">
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Økonomisk Støtte
|
||||
</h2>
|
||||
<p>
|
||||
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 lite, månedlig beløp via Vipps.
|
||||
</p>
|
||||
|
||||
<div className="justify-center my-8">
|
||||
<form className={"flex flex-col mx-4"}>
|
||||
<Input
|
||||
type={"email"}
|
||||
placeholder={"din@epost.no"}
|
||||
required
|
||||
onInput={(e) => {
|
||||
setEmailInput(e.currentTarget.value);
|
||||
}}
|
||||
>
|
||||
</Input>
|
||||
<a
|
||||
// don't navigate if email is not valid
|
||||
href={emailInput ? `/api/trigger-donation/vipps?email=${emailInput}` : undefined}
|
||||
className={"mt-2"}
|
||||
onClick={(e) => {
|
||||
if (!emailValid) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<vipps-mobilepay-button
|
||||
type="submit"
|
||||
brand="vipps"
|
||||
language="no"
|
||||
variant="primary"
|
||||
rounded="false"
|
||||
verb="pay"
|
||||
stretched="true"
|
||||
branded="true"
|
||||
loading="false"
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
</vipps-mobilepay-button>
|
||||
</a>
|
||||
</form>
|
||||
<p
|
||||
className={"text-sm text-gray-500"}
|
||||
>
|
||||
Eposten brukes utelukkende som referanse dersom du ønsker å avslutte støtten. Du kommer ikke til å motta
|
||||
noen eposter.
|
||||
<b>
|
||||
Du kan når som helst avslutte støtten fra{" "}
|
||||
<a className="text-blue-600 underline" href="/donations-cancel">
|
||||
denne siden
|
||||
</a>.
|
||||
</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-4">
|
||||
Opplever du problemer med betalingen, eller har du andre spørsmål? Ta{" "}
|
||||
<a className="text-blue-600 underline" href="mailto:olav@sundfoer.com">
|
||||
kontakt på mail
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,7 +10,7 @@ async function initialFetch(options: { id: string }): Promise<Series | null> {
|
|||
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<UpdatedSeries | Seri
|
|||
episodes: episodesSortedDescending,
|
||||
};
|
||||
|
||||
const updateSuccessful = await storage.write(updated);
|
||||
const updateSuccessful = await storage.writeSeries(updated);
|
||||
if (!updateSuccessful) {
|
||||
console.log(`Failed to update series ${existingSeries.id}`);
|
||||
return null;
|
||||
|
|
@ -76,7 +76,7 @@ function isSeriesFromStorageNew(
|
|||
}
|
||||
|
||||
async function getSeries(options: { id: string }): Promise<Series | null> {
|
||||
const seriesFromStorage = await storage.read(options);
|
||||
const seriesFromStorage = await storage.readSeries(options);
|
||||
|
||||
/**
|
||||
* We don't have the feed in storage,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
17
lib/rss.ts
17
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 {
|
||||
|
|
@ -57,8 +57,19 @@ 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 omtrent det samme som prisen på en kaffekopp. Se mer på ${getHostUrl()}`;
|
||||
|
||||
return `
|
||||
${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),
|
||||
|
|
@ -71,7 +82,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"],
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,26 +18,45 @@ export type Series = {
|
|||
episodes: Episode[];
|
||||
};
|
||||
|
||||
export type VippsAgreement = {
|
||||
// the user identifier / email
|
||||
id: string;
|
||||
// the agreement id from Vipps
|
||||
agreementId: string;
|
||||
createdAt: Date;
|
||||
validAt: Date | null;
|
||||
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<T extends Identifiable>(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 read(options: { id: string }): Promise<Series | null> {
|
||||
const { id } = options;
|
||||
const key = seriesKey({ id });
|
||||
const read = await kv.get<Series>(key);
|
||||
return read.value;
|
||||
function write<T extends Identifiable>(collection: Collection) {
|
||||
return async function (entity: T) {
|
||||
const key = [collection, entity.id];
|
||||
const stored = await kv.set(key, entity);
|
||||
return stored.ok;
|
||||
};
|
||||
}
|
||||
|
||||
async function write(series: Series): Promise<boolean> {
|
||||
const key = seriesKey(series);
|
||||
const stored = await kv.set(key, series);
|
||||
return stored.ok;
|
||||
}
|
||||
const readSeries = read<Series>("series");
|
||||
const writeSeries = write<Series>("series");
|
||||
const readVippsAgreement = read<VippsAgreement>("vipps-agreements");
|
||||
const writeVippsAgreement = write<VippsAgreement>("vipps-agreements");
|
||||
|
||||
export const storage = {
|
||||
read,
|
||||
write,
|
||||
readSeries,
|
||||
writeSeries,
|
||||
readVippsAgreement,
|
||||
writeVippsAgreement,
|
||||
};
|
||||
|
|
|
|||
18
lib/utils.ts
18
lib/utils.ts
|
|
@ -1,11 +1,15 @@
|
|||
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 {
|
||||
// assume env
|
||||
console.debug(`Assuming localhost`);
|
||||
return "http://localhost:8000";
|
||||
}
|
||||
}
|
||||
|
|
@ -30,3 +34,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
// deno-lint-ignore-file ban-ts-comment
|
||||
import "jsr:@std/dotenv/load";
|
||||
import { getHostUrl } 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"),
|
||||
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",
|
||||
// @ts-ignore
|
||||
headers: {
|
||||
"client_id": config.clientId,
|
||||
"client_secret": config.clientSecret,
|
||||
...standardVippsHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.access_token as string;
|
||||
};
|
||||
|
||||
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
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"Idempotency-Key": `${email}-${Date.now()}`,
|
||||
...standardVippsHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"interval": {
|
||||
"unit": "MONTH",
|
||||
"count": 1,
|
||||
},
|
||||
"initialCharge": {
|
||||
"amount": amount,
|
||||
"description": "Initial charge",
|
||||
"transactionType": "DIRECT_CAPTURE",
|
||||
},
|
||||
"pricing": {
|
||||
"amount": amount,
|
||||
"currency": "NOK",
|
||||
},
|
||||
// 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",
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === STATUS_CODE.Created) {
|
||||
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()}`;
|
||||
console.error(errorMessage);
|
||||
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}`, {
|
||||
// @ts-ignore
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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()}`,
|
||||
...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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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) {
|
|||
<title>NRSS</title>
|
||||
</head>
|
||||
<body class="bg-slate-50">
|
||||
<Header></Header>
|
||||
<Component />
|
||||
<Footer></Footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
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<Response> {
|
||||
const email = new URLSearchParams(req.url.split("?")[1] || "").get("email");
|
||||
const errorPage = `${getHostUrl()}/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 existingAgreement = await storage.readVippsAgreement({ id: email });
|
||||
if (existingAgreement && existingAgreement.revokedAt === null) {
|
||||
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);
|
||||
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);
|
||||
};
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
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";
|
||||
|
||||
type Props = {
|
||||
email: string | null;
|
||||
error: string | null;
|
||||
cancelled: boolean;
|
||||
};
|
||||
|
||||
export const handler: Handlers<Props> = {
|
||||
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<Props>) {
|
||||
return (
|
||||
<div
|
||||
className={"p-4 mx-auto max-w-screen-md text-center"}
|
||||
>
|
||||
<h1
|
||||
className={"text-3xl mb-6"}
|
||||
>
|
||||
Avslutt støtte til NRSS
|
||||
</h1>
|
||||
{data.error && (
|
||||
<p className={"text-red-500"}>
|
||||
En feil oppsto: "{data.error}". Ta gjerne{" "}
|
||||
<a className="text-blue-600 underline" href="mailto:olav@sundfoer.com">
|
||||
kontakt på mail
|
||||
</a>.
|
||||
</p>
|
||||
)}
|
||||
{data.cancelled && (
|
||||
<p className={"text-left m-4"}>
|
||||
Støtten er nå avsluttet. <br />
|
||||
<b>Tusen takk for at du har støttet NRSS 🙏</b>
|
||||
</p>
|
||||
)}
|
||||
{!data.cancelled && (
|
||||
<form className={"flex flex-col w-full"}>
|
||||
<Input
|
||||
type={"email"}
|
||||
placeholder={"din@epost.no"}
|
||||
required
|
||||
name={"email"}
|
||||
id={"email"}
|
||||
/>
|
||||
<Button
|
||||
className={"w-full"}
|
||||
type={"submit"}
|
||||
>
|
||||
Avslutt
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
export default function () {
|
||||
return (
|
||||
<div className="my-16 text-center flex flex-col items-center min-h-screen ">
|
||||
<h1 className="text-6xl mb-8">
|
||||
💥🔧🚨😱
|
||||
</h1>
|
||||
<p className="text-4xl mb-4">
|
||||
Auda. Noe gikk galt. Det er flaut.
|
||||
</p>
|
||||
<p className="text-4xl">
|
||||
Forteller du meg om feilen{" "}
|
||||
<a className="text-blue-600 underline" href="mailto:olav@sundfoer.com">
|
||||
på mail
|
||||
</a>{" "}
|
||||
blir jeg veldig glad.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { Handlers } from "$fresh/server.ts";
|
||||
import { storage } from "../lib/storage.ts";
|
||||
import { validateEmail } from "../lib/utils.ts";
|
||||
|
||||
export const handler: Handlers = {
|
||||
async GET(request, ctx) {
|
||||
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");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
await storage.writeVippsAgreement({
|
||||
...agreement,
|
||||
validAt: new Date(),
|
||||
});
|
||||
|
||||
return ctx.render({});
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<div className="my-16 text-center flex flex-col items-center min-h-screen ">
|
||||
<h1 className="text-6xl mb-8">
|
||||
🙏🌟😊💖
|
||||
</h1>
|
||||
<p className="text-4xl mb-4">
|
||||
Tusen takk! Jeg er veldig takknemlig for all støtte.
|
||||
</p>
|
||||
<p>
|
||||
<a className="text-blue-600 underline" href="/">
|
||||
Gå tilbake til forsiden
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Props>) {
|
|||
/>
|
||||
</Head>
|
||||
<div className="p-4 mx-auto max-w-screen-md">
|
||||
<Header />
|
||||
<Search defaultValue={data.query} />
|
||||
<SearchResult result={data.result} origin={url.origin} />
|
||||
<div
|
||||
class="markdown-body"
|
||||
dangerouslySetInnerHTML={{ __html: render(data?.rawMarkdown) }}
|
||||
/>
|
||||
<Footer />
|
||||
<DonationSection />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue