diff --git a/packages/app/src/Element/Nip5Service.tsx b/packages/app/src/Element/Nip5Service.tsx index 4106d944..20962783 100644 --- a/packages/app/src/Element/Nip5Service.tsx +++ b/packages/app/src/Element/Nip5Service.tsx @@ -21,6 +21,7 @@ import { useUserProfile } from "Hooks/useUserProfile"; import useEventPublisher from "Feed/EventPublisher"; import { debounce } from "Util"; import useLogin from "Hooks/useLogin"; +import SnortServiceProvider from "Nip05/SnortServiceProvider"; import messages from "./messages"; @@ -31,6 +32,7 @@ type Nip05ServiceProps = { link: string; supportLink: string; helpText?: boolean; + forSubscription?: string; onChange?(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) { if (user && publisher) { const nip05 = `${handle}@${domain}`; @@ -245,16 +263,27 @@ export default function Nip5Service(props: Nip05ServiceProps) { )} {availabilityResponse?.available && !registerStatus && (
-
- -
- {availabilityResponse.quote?.data.type} -
- startBuy(handle, domain)}> - + {!props.forSubscription && ( +
+ +
+ {availabilityResponse.quote?.data.type} +
+ )} + + props.forSubscription + ? claimForSubscription(handle, domain, props.forSubscription) + : startBuy(handle, domain) + }> + {props.forSubscription ? ( + + ) : ( + + )}
)} diff --git a/packages/app/src/Nip05/SnortServiceProvider.ts b/packages/app/src/Nip05/SnortServiceProvider.ts index 4fcc6d36..c4d58a1d 100644 --- a/packages/app/src/Nip05/SnortServiceProvider.ts +++ b/packages/app/src/Nip05/SnortServiceProvider.ts @@ -42,6 +42,15 @@ export default class SnortServiceProvider extends ServiceProvider { return this.getJsonAuthd(`/${id}`, "PATCH", obj); } + async registerForSubscription(handle: string, domain: string, id: string) { + return this.getJsonAuthd(`/registration/register/${id}`, "PUT", { + name: handle, + domain, + pk: "", + ref: "snort", + }); + } + async getJsonAuthd( path: string, method?: "GET" | string, diff --git a/packages/app/src/Pages/Verification.tsx b/packages/app/src/Pages/Verification.tsx index 25ebfd5b..158a0889 100644 --- a/packages/app/src/Pages/Verification.tsx +++ b/packages/app/src/Pages/Verification.tsx @@ -7,14 +7,16 @@ import messages from "./messages"; import "./Verification.css"; -export const services = [ - { - name: "Snort", - service: `${ApiHost}/api/v1/n5sp`, - link: "https://snort.social/", - supportLink: "https://snort.social/help", - about: , - }, +export const SnortNostrAddressService = { + name: "Snort", + service: `${ApiHost}/api/v1/n5sp`, + link: "https://snort.social/", + supportLink: "https://snort.social/help", + about: , +}; + +export const Nip5Services = [ + SnortNostrAddressService, { name: "Nostr Plebs", service: "https://nostrplebs.com/api/v1", @@ -48,7 +50,7 @@ export default function VerificationPage() { - {services.map(a => ( + {Nip5Services.map(a => ( ))} diff --git a/packages/app/src/Pages/new/GetVerified.tsx b/packages/app/src/Pages/new/GetVerified.tsx index d3d7436f..a420596c 100644 --- a/packages/app/src/Pages/new/GetVerified.tsx +++ b/packages/app/src/Pages/new/GetVerified.tsx @@ -3,7 +3,7 @@ import { FormattedMessage } from "react-intl"; import { useNavigate } from "react-router-dom"; import Logo from "Element/Logo"; -import { services } from "Pages/Verification"; +import { Nip5Services } from "Pages/Verification"; import Nip5Service from "Element/Nip5Service"; import ProfileImage from "Element/ProfileImage"; import { useUserProfile } from "Hooks/useUserProfile"; @@ -66,7 +66,7 @@ export default function GetVerified() {
setIsVerified(true)} @@ -85,7 +85,7 @@ export default function GetVerified() {
setIsVerified(true)} diff --git a/packages/app/src/Pages/subscribe/ManageSubscription.tsx b/packages/app/src/Pages/subscribe/ManageSubscription.tsx index 6ed3a787..5da37bd4 100644 --- a/packages/app/src/Pages/subscribe/ManageSubscription.tsx +++ b/packages/app/src/Pages/subscribe/ManageSubscription.tsx @@ -1,19 +1,19 @@ import { useEffect, useState } from "react"; -import { FormattedDate, FormattedMessage, FormattedNumber } from "react-intl"; +import { FormattedMessage } from "react-intl"; import { Link } from "react-router-dom"; import PageSpinner from "Element/PageSpinner"; import useEventPublisher from "Feed/EventPublisher"; -import SnortApi, { Subscription } from "SnortApi"; -import { mapPlanName } from "."; -import Icon from "Icons/Icon"; +import SnortApi, { Subscription, SubscriptionError } from "SnortApi"; +import { mapSubscriptionErrorCode } from "."; +import SubscriptionCard from "./SubscriptionCard"; export default function ManageSubscriptionPage() { const publisher = useEventPublisher(); const api = new SnortApi(undefined, publisher); const [subs, setSubs] = useState>(); - const [error, setError] = useState(""); + const [error, setError] = useState(); useEffect(() => { (async () => { @@ -21,10 +21,8 @@ export default function ManageSubscriptionPage() { const s = await api.listSubscriptions(); setSubs(s); } catch (e) { - if (e instanceof Error) { - setError(e.message); - } else { - setError("Unknown error"); + if (e instanceof SubscriptionError) { + setError(e); } } })(); @@ -38,71 +36,9 @@ export default function ManageSubscriptionPage() {

- {subs.map(a => { - const created = new Date(a.created); - 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 ( -
-
- - {mapPlanName(a.type)} -
-
-

- - :  - -

- {daysToExpire >= 1 && ( -

- - :  - -

- )} - {daysToExpire >= 0 && daysToExpire < 1 && ( -

- - :  - -

- )} - {daysToExpire < 0 && ( -

- -

- )} -
- {isExpired && ( -
- -
- )} -
- ); - })} + {subs.map(a => ( + + ))} {subs.length === 0 && (

)} - {error && {error}} + {error && {mapSubscriptionErrorCode(error)}} ); } diff --git a/packages/app/src/Pages/subscribe/SubscriptionCard.tsx b/packages/app/src/Pages/subscribe/SubscriptionCard.tsx new file mode 100644 index 00000000..6bc053be --- /dev/null +++ b/packages/app/src/Pages/subscribe/SubscriptionCard.tsx @@ -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(); + + 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 && ( + <> +

+ +

+ (sub.handle = h)} + /> + + )} + {sub.handle && } + + ); + } + + return ( + <> +
+
+ + {mapPlanName(sub.type)} +
+
+

+ + :  + +

+ {daysToExpire >= 1 && ( +

+ + :  + +

+ )} + {daysToExpire >= 0 && daysToExpire < 1 && ( +

+ + :  + +

+ )} + {isExpired && ( +

+ +

+ )} + {isNew && ( +

+ +

+ )} +
+ {(isExpired || isNew) && ( +
+ renew(sub.id)}> + {isExpired ? : } + +
+ )} + {isPaid && subFeatures()} +
+ setInvoice("")} + title={formatMessage({ + defaultMessage: "Pay for subscription", + })} + /> + {error && {mapSubscriptionErrorCode(error)}} + + ); +} diff --git a/packages/app/src/Pages/subscribe/index.tsx b/packages/app/src/Pages/subscribe/index.tsx index ae3d1c74..78cae993 100644 --- a/packages/app/src/Pages/subscribe/index.tsx +++ b/packages/app/src/Pages/subscribe/index.tsx @@ -9,7 +9,7 @@ import { LockedFeatures, Plans, SubscriptionType } from "Subscription"; import ManageSubscriptionPage from "Pages/subscribe/ManageSubscription"; import AsyncButton from "Element/AsyncButton"; import useEventPublisher from "Feed/EventPublisher"; -import SnortApi from "SnortApi"; +import SnortApi, { SubscriptionError, SubscriptionErrorCode } from "SnortApi"; import SendSats from "Element/SendSats"; 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 ; + case SubscriptionErrorCode.SubscriptionActive: + return ; + case SubscriptionErrorCode.Duplicate: + return ; + default: + return c.message; + } +} + export function SubscribePage() { const publisher = useEventPublisher(); const api = new SnortApi(undefined, publisher); const [invoice, setInvoice] = useState(""); + const [error, setError] = useState(); async function subscribe(type: number) { - const rsp = await api.createSubscription(type); - setInvoice(rsp.pr); + setError(undefined); + try { + const rsp = await api.createSubscription(type); + setInvoice(rsp.pr); + } catch (e) { + if (e instanceof SubscriptionError) { + setError(e); + } + } } return ( -
- {Plans.map(a => { - const lower = Plans.filter(b => b.id < a.id); - return ( -
-
-

{mapPlanName(a.id)}

-

- {formatShort(a.price)} sats/mo, - }} - /> - : -

- - - -
    - {a.unlocks.map(b => ( -
  • {mapFeatureName(b)}
  • - ))} - {lower.map(b => ( -
  • - -
  • - ))} -
+ <> +
+ {Plans.map(a => { + const lower = Plans.filter(b => b.id < a.id); + return ( +
+
+

{mapPlanName(a.id)}

+

+ {formatShort(a.price)} sats/mo, + }} + /> + : +

+ + + +
    + {a.unlocks.map(b => ( +
  • {mapFeatureName(b)}
  • + ))} + {lower.map(b => ( +
  • + +
  • + ))} +
+
+
+ subscribe(a.id)}> + {a.disabled ? ( + + ) : ( + + )} + +
-
- subscribe(a.id)}> - {a.disabled ? ( - - ) : ( - - )} - -
-
- ); - })} + ); + })} +
+ {error && {mapSubscriptionErrorCode(error)}} setInvoice("")} /> -
+ ); } diff --git a/packages/app/src/SnortApi.ts b/packages/app/src/SnortApi.ts index 63917cce..f7625796 100644 --- a/packages/app/src/SnortApi.ts +++ b/packages/app/src/SnortApi.ts @@ -20,8 +20,25 @@ export interface InvoiceResponse { export interface Subscription { id: string; type: SubscriptionType; - created: string; - expires: string; + created: number; + 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 { @@ -49,6 +66,10 @@ export default class SnortApi { return this.#getJsonAuthd(`api/v1/subscription?type=${type}`, "PUT"); } + renewSubscription(id: string) { + return this.#getJsonAuthd(`api/v1/subscription/${id}/renew`, "GET"); + } + listSubscriptions() { return this.#getJsonAuthd>("api/v1/subscription"); } @@ -93,7 +114,7 @@ export default class SnortApi { const obj = await rsp.json(); if ("error" in obj) { - throw new Error(obj.error); + throw new SubscriptionError(obj.error, obj.code); } return obj as T; }