Merge pull request #522 from v0l/subscription-handle

Subscription handle
This commit is contained in:
Kieran 2023-04-18 10:40:13 +01:00 committed by GitHub
commit 4f1e5df1a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 309 additions and 151 deletions

View File

@ -21,6 +21,7 @@ import { useUserProfile } from "Hooks/useUserProfile";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { debounce } from "Util"; import { debounce } from "Util";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import SnortServiceProvider from "Nip05/SnortServiceProvider";
import messages from "./messages"; import messages from "./messages";
@ -31,6 +32,7 @@ type Nip05ServiceProps = {
link: string; link: string;
supportLink: string; supportLink: string;
helpText?: boolean; helpText?: boolean;
forSubscription?: string;
onChange?(h: string): void; onChange?(h: string): void;
onSuccess?(h: string): void; onSuccess?(h: string): void;
}; };
@ -188,6 +190,22 @@ export default function Nip5Service(props: Nip05ServiceProps) {
} }
} }
async function claimForSubscription(handle: string, domain: string, sub: string) {
if (!pubkey || !publisher) {
return;
}
const svcEx = new SnortServiceProvider(publisher, props.service);
const rsp = await svcEx.registerForSubscription(handle, domain, sub);
if ("error" in rsp) {
setError(rsp);
} else {
if (props.onSuccess) {
const nip05 = `${handle}@${domain}`;
props.onSuccess(nip05);
}
}
}
async function updateProfile(handle: string, domain: string) { async function updateProfile(handle: string, domain: string) {
if (user && publisher) { if (user && publisher) {
const nip05 = `${handle}@${domain}`; const nip05 = `${handle}@${domain}`;
@ -245,16 +263,27 @@ export default function Nip5Service(props: Nip05ServiceProps) {
)} )}
{availabilityResponse?.available && !registerStatus && ( {availabilityResponse?.available && !registerStatus && (
<div className="flex"> <div className="flex">
<div className="mr10"> {!props.forSubscription && (
<FormattedMessage <div className="mr10">
{...messages.Sats} <FormattedMessage
values={{ n: formatShort(unwrap(availabilityResponse.quote?.price)) }} {...messages.Sats}
/> values={{ n: formatShort(unwrap(availabilityResponse.quote?.price)) }}
<br /> />
<small>{availabilityResponse.quote?.data.type}</small> <br />
</div> <small>{availabilityResponse.quote?.data.type}</small>
<AsyncButton onClick={() => startBuy(handle, domain)}> </div>
<FormattedMessage {...messages.BuyNow} /> )}
<AsyncButton
onClick={() =>
props.forSubscription
? claimForSubscription(handle, domain, props.forSubscription)
: startBuy(handle, domain)
}>
{props.forSubscription ? (
<FormattedMessage defaultMessage="Claim Now" />
) : (
<FormattedMessage {...messages.BuyNow} />
)}
</AsyncButton> </AsyncButton>
</div> </div>
)} )}

View File

@ -42,6 +42,15 @@ export default class SnortServiceProvider extends ServiceProvider {
return this.getJsonAuthd<object>(`/${id}`, "PATCH", obj); return this.getJsonAuthd<object>(`/${id}`, "PATCH", obj);
} }
async registerForSubscription(handle: string, domain: string, id: string) {
return this.getJsonAuthd<object>(`/registration/register/${id}`, "PUT", {
name: handle,
domain,
pk: "",
ref: "snort",
});
}
async getJsonAuthd<T>( async getJsonAuthd<T>(
path: string, path: string,
method?: "GET" | string, method?: "GET" | string,

View File

@ -7,14 +7,16 @@ import messages from "./messages";
import "./Verification.css"; import "./Verification.css";
export const services = [ export const SnortNostrAddressService = {
{ name: "Snort",
name: "Snort", service: `${ApiHost}/api/v1/n5sp`,
service: `${ApiHost}/api/v1/n5sp`, link: "https://snort.social/",
link: "https://snort.social/", supportLink: "https://snort.social/help",
supportLink: "https://snort.social/help", about: <FormattedMessage {...messages.SnortSocialNip} />,
about: <FormattedMessage {...messages.SnortSocialNip} />, };
},
export const Nip5Services = [
SnortNostrAddressService,
{ {
name: "Nostr Plebs", name: "Nostr Plebs",
service: "https://nostrplebs.com/api/v1", service: "https://nostrplebs.com/api/v1",
@ -48,7 +50,7 @@ export default function VerificationPage() {
</li> </li>
</ul> </ul>
{services.map(a => ( {Nip5Services.map(a => (
<Nip5Service key={a.name} {...a} /> <Nip5Service key={a.name} {...a} />
))} ))}
</div> </div>

View File

@ -3,7 +3,7 @@ import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Logo from "Element/Logo"; import Logo from "Element/Logo";
import { services } from "Pages/Verification"; import { Nip5Services } from "Pages/Verification";
import Nip5Service from "Element/Nip5Service"; import Nip5Service from "Element/Nip5Service";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
import { useUserProfile } from "Hooks/useUserProfile"; import { useUserProfile } from "Hooks/useUserProfile";
@ -66,7 +66,7 @@ export default function GetVerified() {
<div className="nip-container"> <div className="nip-container">
<Nip5Service <Nip5Service
key="snort" key="snort"
{...services[0]} {...Nip5Services[0]}
helpText={false} helpText={false}
onChange={setNip05} onChange={setNip05}
onSuccess={() => setIsVerified(true)} onSuccess={() => setIsVerified(true)}
@ -85,7 +85,7 @@ export default function GetVerified() {
<div className="nip-container"> <div className="nip-container">
<Nip5Service <Nip5Service
key="nostrplebs" key="nostrplebs"
{...services[1]} {...Nip5Services[1]}
helpText={false} helpText={false}
onChange={setNip05} onChange={setNip05}
onSuccess={() => setIsVerified(true)} onSuccess={() => setIsVerified(true)}

View File

@ -1,19 +1,19 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FormattedDate, FormattedMessage, FormattedNumber } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import PageSpinner from "Element/PageSpinner"; import PageSpinner from "Element/PageSpinner";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import SnortApi, { Subscription } from "SnortApi"; import SnortApi, { Subscription, SubscriptionError } from "SnortApi";
import { mapPlanName } from "."; import { mapSubscriptionErrorCode } from ".";
import Icon from "Icons/Icon"; import SubscriptionCard from "./SubscriptionCard";
export default function ManageSubscriptionPage() { export default function ManageSubscriptionPage() {
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const api = new SnortApi(undefined, publisher); const api = new SnortApi(undefined, publisher);
const [subs, setSubs] = useState<Array<Subscription>>(); const [subs, setSubs] = useState<Array<Subscription>>();
const [error, setError] = useState(""); const [error, setError] = useState<SubscriptionError>();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -21,10 +21,8 @@ export default function ManageSubscriptionPage() {
const s = await api.listSubscriptions(); const s = await api.listSubscriptions();
setSubs(s); setSubs(s);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof SubscriptionError) {
setError(e.message); setError(e);
} else {
setError("Unknown error");
} }
} }
})(); })();
@ -38,71 +36,9 @@ export default function ManageSubscriptionPage() {
<h2> <h2>
<FormattedMessage defaultMessage="Subscriptions" /> <FormattedMessage defaultMessage="Subscriptions" />
</h2> </h2>
{subs.map(a => { {subs.map(a => (
const created = new Date(a.created); <SubscriptionCard sub={a} key={a.id} />
const expires = new Date(a.expires); ))}
const now = new Date();
const daysToExpire = Math.floor((expires.getTime() - now.getTime()) / 8.64e7);
const hoursToExpire = Math.floor((expires.getTime() - now.getTime()) / 3.6e6);
const isExpired = expires < now;
return (
<div key={a.id} className="card">
<div className="flex card-title">
<Icon name="badge" className="mr5" size={25} />
{mapPlanName(a.type)}
</div>
<div className="flex">
<p className="f-1">
<FormattedMessage defaultMessage="Created" />
:&nbsp;
<time dateTime={created.toISOString()}>
<FormattedDate value={created} dateStyle="full" />
</time>
</p>
{daysToExpire >= 1 && (
<p className="f-1">
<FormattedMessage defaultMessage="Expires" />
:&nbsp;
<time dateTime={expires.toISOString()}>
<FormattedMessage
defaultMessage="{n} days"
values={{
n: <FormattedNumber value={daysToExpire} maximumFractionDigits={0} />,
}}
/>
</time>
</p>
)}
{daysToExpire >= 0 && daysToExpire < 1 && (
<p className="f-1">
<FormattedMessage defaultMessage="Expires" />
:&nbsp;
<time dateTime={expires.toISOString()}>
<FormattedMessage
defaultMessage="{n} hours"
values={{
n: <FormattedNumber value={hoursToExpire} maximumFractionDigits={0} />,
}}
/>
</time>
</p>
)}
{daysToExpire < 0 && (
<p className="f-1 error">
<FormattedMessage defaultMessage="Expired" />
</p>
)}
</div>
{isExpired && (
<div className="flex">
<button>
<FormattedMessage defaultMessage="Renew" />
</button>
</div>
)}
</div>
);
})}
{subs.length === 0 && ( {subs.length === 0 && (
<p> <p>
<FormattedMessage <FormattedMessage
@ -117,7 +53,7 @@ export default function ManageSubscriptionPage() {
/> />
</p> </p>
)} )}
{error && <b className="error">{error}</b>} {error && <b className="error">{mapSubscriptionErrorCode(error)}</b>}
</> </>
); );
} }

View File

@ -0,0 +1,137 @@
import { FormattedMessage, FormattedDate, FormattedNumber, useIntl } from "react-intl";
import { useState } from "react";
import SnortApi, { Subscription, SubscriptionError } from "SnortApi";
import { mapPlanName, mapSubscriptionErrorCode } from ".";
import AsyncButton from "Element/AsyncButton";
import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher";
import SendSats from "Element/SendSats";
import Nip5Service from "Element/Nip5Service";
import { SnortNostrAddressService } from "Pages/Verification";
import Nip05 from "Element/Nip05";
export default function SubscriptionCard({ sub }: { sub: Subscription }) {
const publisher = useEventPublisher();
const { formatMessage } = useIntl();
const created = new Date(sub.created * 1000);
const expires = new Date(sub.expires * 1000);
const now = new Date();
const daysToExpire = Math.floor((expires.getTime() - now.getTime()) / 8.64e7);
const hoursToExpire = Math.floor((expires.getTime() - now.getTime()) / 3.6e6);
const isExpired = sub.state === "expired";
const isNew = sub.state === "new";
const isPaid = sub.state === "paid";
const [invoice, setInvoice] = useState("");
const [error, setError] = useState<SubscriptionError>();
async function renew(id: string) {
const api = new SnortApi(undefined, publisher);
try {
const rsp = await api.renewSubscription(id);
setInvoice(rsp.pr);
} catch (e) {
if (e instanceof SubscriptionError) {
setError(e);
}
}
}
function subFeatures() {
return (
<>
{!sub.handle && (
<>
<h3>
<FormattedMessage defaultMessage="Claim your included Snort nostr address" />
</h3>
<Nip5Service
{...SnortNostrAddressService}
helpText={false}
forSubscription={sub.id}
onSuccess={h => (sub.handle = h)}
/>
</>
)}
{sub.handle && <Nip05 nip05={sub.handle} pubkey={""} verifyNip={false} />}
</>
);
}
return (
<>
<div className="card">
<div className="flex card-title">
<Icon name="badge" className="mr5" size={25} />
{mapPlanName(sub.type)}
</div>
<div className="flex">
<p className="f-1">
<FormattedMessage defaultMessage="Created" />
:&nbsp;
<time dateTime={created.toISOString()}>
<FormattedDate value={created} dateStyle="full" />
</time>
</p>
{daysToExpire >= 1 && (
<p className="f-1">
<FormattedMessage defaultMessage="Expires" />
:&nbsp;
<time dateTime={expires.toISOString()}>
<FormattedMessage
defaultMessage="{n} days"
values={{
n: <FormattedNumber value={daysToExpire} maximumFractionDigits={0} />,
}}
/>
</time>
</p>
)}
{daysToExpire >= 0 && daysToExpire < 1 && (
<p className="f-1">
<FormattedMessage defaultMessage="Expires" />
:&nbsp;
<time dateTime={expires.toISOString()}>
<FormattedMessage
defaultMessage="{n} hours"
values={{
n: <FormattedNumber value={hoursToExpire} maximumFractionDigits={0} />,
}}
/>
</time>
</p>
)}
{isExpired && (
<p className="f-1 error">
<FormattedMessage defaultMessage="Expired" />
</p>
)}
{isNew && (
<p className="f-1">
<FormattedMessage defaultMessage="Unpaid" />
</p>
)}
</div>
{(isExpired || isNew) && (
<div className="flex">
<AsyncButton onClick={() => renew(sub.id)}>
{isExpired ? <FormattedMessage defaultMessage="Renew" /> : <FormattedMessage defaultMessage="Pay Now" />}
</AsyncButton>
</div>
)}
{isPaid && subFeatures()}
</div>
<SendSats
invoice={invoice}
show={invoice !== ""}
onClose={() => setInvoice("")}
title={formatMessage({
defaultMessage: "Pay for subscription",
})}
/>
{error && <b className="error">{mapSubscriptionErrorCode(error)}</b>}
</>
);
}

View File

@ -9,7 +9,7 @@ import { LockedFeatures, Plans, SubscriptionType } from "Subscription";
import ManageSubscriptionPage from "Pages/subscribe/ManageSubscription"; import ManageSubscriptionPage from "Pages/subscribe/ManageSubscription";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import SnortApi from "SnortApi"; import SnortApi, { SubscriptionError, SubscriptionErrorCode } from "SnortApi";
import SendSats from "Element/SendSats"; import SendSats from "Element/SendSats";
export function mapPlanName(id: number) { export function mapPlanName(id: number) {
@ -38,67 +38,91 @@ export function mapFeatureName(k: LockedFeatures) {
} }
} }
export function mapSubscriptionErrorCode(c: SubscriptionError) {
switch (c.code) {
case SubscriptionErrorCode.InternalError:
return <FormattedMessage defaultMessage="Internal error: {msg}" values={{ msg: c.message }} />;
case SubscriptionErrorCode.SubscriptionActive:
return <FormattedMessage defaultMessage="You subscription is still active, you can't renew yet" />;
case SubscriptionErrorCode.Duplicate:
return <FormattedMessage defaultMessage="You already have a subscription of this type, please renew or pay" />;
default:
return c.message;
}
}
export function SubscribePage() { export function SubscribePage() {
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const api = new SnortApi(undefined, publisher); const api = new SnortApi(undefined, publisher);
const [invoice, setInvoice] = useState(""); const [invoice, setInvoice] = useState("");
const [error, setError] = useState<SubscriptionError>();
async function subscribe(type: number) { async function subscribe(type: number) {
const rsp = await api.createSubscription(type); setError(undefined);
setInvoice(rsp.pr); try {
const rsp = await api.createSubscription(type);
setInvoice(rsp.pr);
} catch (e) {
if (e instanceof SubscriptionError) {
setError(e);
}
}
} }
return ( return (
<div className="flex subscribe-page"> <>
{Plans.map(a => { <div className="flex subscribe-page">
const lower = Plans.filter(b => b.id < a.id); {Plans.map(a => {
return ( const lower = Plans.filter(b => b.id < a.id);
<div className={`card flex f-col${a.disabled ? " disabled" : ""}`}> return (
<div className="f-grow"> <div className={`card flex f-col${a.disabled ? " disabled" : ""}`}>
<h2>{mapPlanName(a.id)}</h2> <div className="f-grow">
<p> <h2>{mapPlanName(a.id)}</h2>
<FormattedMessage <p>
defaultMessage="Subscribe to Snort {plan} for {price} and receive the following rewards" <FormattedMessage
values={{ defaultMessage="Subscribe to Snort {plan} for {price} and receive the following rewards"
plan: mapPlanName(a.id), values={{
price: <b>{formatShort(a.price)} sats/mo</b>, plan: mapPlanName(a.id),
}} price: <b>{formatShort(a.price)} sats/mo</b>,
/> }}
: />
</p> :
<b> </p>
<FormattedMessage defaultMessage="Not all features are built yet, more features to be added soon!" /> <b>
</b> <FormattedMessage defaultMessage="Not all features are built yet, more features to be added soon!" />
<ul> </b>
{a.unlocks.map(b => ( <ul>
<li>{mapFeatureName(b)} </li> {a.unlocks.map(b => (
))} <li>{mapFeatureName(b)} </li>
{lower.map(b => ( ))}
<li> {lower.map(b => (
<FormattedMessage <li>
defaultMessage="Everything in {plan}" <FormattedMessage
values={{ defaultMessage="Everything in {plan}"
plan: mapPlanName(b.id), values={{
}} plan: mapPlanName(b.id),
/> }}
</li> />
))} </li>
</ul> ))}
</ul>
</div>
<div className="flex f-center w-max mb10">
<AsyncButton className="button" disabled={a.disabled} onClick={() => subscribe(a.id)}>
{a.disabled ? (
<FormattedMessage defaultMessage="Coming soon" />
) : (
<FormattedMessage defaultMessage="Subscribe" />
)}
</AsyncButton>
</div>
</div> </div>
<div className="flex f-center w-max mb10"> );
<AsyncButton className="button" disabled={a.disabled} onClick={() => subscribe(a.id)}> })}
{a.disabled ? ( </div>
<FormattedMessage defaultMessage="Coming soon" /> {error && <b className="error">{mapSubscriptionErrorCode(error)}</b>}
) : (
<FormattedMessage defaultMessage="Subscribe" />
)}
</AsyncButton>
</div>
</div>
);
})}
<SendSats invoice={invoice} show={invoice !== ""} onClose={() => setInvoice("")} /> <SendSats invoice={invoice} show={invoice !== ""} onClose={() => setInvoice("")} />
</div> </>
); );
} }

View File

@ -20,8 +20,25 @@ export interface InvoiceResponse {
export interface Subscription { export interface Subscription {
id: string; id: string;
type: SubscriptionType; type: SubscriptionType;
created: string; created: number;
expires: string; expires: number;
state: "new" | "expired" | "paid";
handle?: string;
}
export enum SubscriptionErrorCode {
InternalError = 1,
SubscriptionActive = 2,
Duplicate = 3,
}
export class SubscriptionError extends Error {
code: SubscriptionErrorCode;
constructor(msg: string, code: SubscriptionErrorCode) {
super(msg);
this.code = code;
}
} }
export default class SnortApi { export default class SnortApi {
@ -49,6 +66,10 @@ export default class SnortApi {
return this.#getJsonAuthd<InvoiceResponse>(`api/v1/subscription?type=${type}`, "PUT"); return this.#getJsonAuthd<InvoiceResponse>(`api/v1/subscription?type=${type}`, "PUT");
} }
renewSubscription(id: string) {
return this.#getJsonAuthd<InvoiceResponse>(`api/v1/subscription/${id}/renew`, "GET");
}
listSubscriptions() { listSubscriptions() {
return this.#getJsonAuthd<Array<Subscription>>("api/v1/subscription"); return this.#getJsonAuthd<Array<Subscription>>("api/v1/subscription");
} }
@ -93,7 +114,7 @@ export default class SnortApi {
const obj = await rsp.json(); const obj = await rsp.json();
if ("error" in obj) { if ("error" in obj) {
throw new Error(obj.error); throw new SubscriptionError(obj.error, obj.code);
} }
return obj as T; return obj as T;
} }