feat: subscriptions

This commit is contained in:
2023-04-13 19:43:43 +01:00
parent c3c1e02ad8
commit f0c5c33c48
19 changed files with 531 additions and 58 deletions

View File

@ -1,10 +1,12 @@
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { HexKey } from "@snort/nostr";
import { ApiHost, KieranPubKey, SnortPubKey } from "Const";
import ProfilePreview from "Element/ProfilePreview";
import ZapButton from "Element/ZapButton";
import { HexKey } from "@snort/nostr";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { bech32ToHex } from "Util";
import SnortApi, { RevenueSplit, RevenueToday } from "SnortApi";
const Developers = [
bech32ToHex(KieranPubKey), // kieran
@ -42,29 +44,16 @@ const Translators = [
export const DonateLNURL = "donate@snort.social";
interface Splits {
pubKey: string;
split: number;
}
interface TotalToday {
donations: number;
nip5: number;
}
const DonatePage = () => {
const [splits, setSplits] = useState<Splits[]>([]);
const [today, setSumToday] = useState<TotalToday>();
const [splits, setSplits] = useState<RevenueSplit[]>([]);
const [today, setSumToday] = useState<RevenueToday>();
const api = new SnortApi(ApiHost);
async function loadData() {
const rsp = await fetch(`${ApiHost}/api/v1/revenue/splits`);
if (rsp.ok) {
setSplits(await rsp.json());
}
const rsp2 = await fetch(`${ApiHost}/api/v1/revenue/today`);
if (rsp2.ok) {
setSumToday(await rsp2.json());
}
const rsp = await api.revenueSplits();
setSplits(rsp);
const rsp2 = await api.revenueToday();
setSumToday(rsp2);
}
useEffect(() => {

View File

@ -1,8 +1,13 @@
.logo {
cursor: pointer;
}
.logo h1 {
font-weight: 700;
font-size: 29px;
line-height: 23px;
padding: 0;
margin: 0;
}
header {
@ -10,14 +15,7 @@ header {
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 72px;
padding: 0 12px;
}
@media (min-width: 720px) {
header {
padding: 0;
}
padding: 4px 12px;
}
header .pfp .avatar-wrapper {

View File

@ -24,6 +24,7 @@ import { DefaultRelays, SnortPubKey } from "Const";
import SubDebug from "Element/SubDebug";
import { preload } from "Cache";
import { useDmCache } from "Hooks/useDmsCache";
import { mapPlanName } from "./subscribe";
export default function Layout() {
const location = useLocation();
@ -32,7 +33,9 @@ export default function Layout() {
const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing;
const dispatch = useDispatch();
const navigate = useNavigate();
const { loggedOut, publicKey, relays, preferences, newUserKey } = useSelector((s: RootState) => s.login);
const { loggedOut, publicKey, relays, preferences, newUserKey, subscription } = useSelector(
(s: RootState) => s.login
);
const [pageClass, setPageClass] = useState("page");
const pub = useEventPublisher();
useLoginFeed();
@ -45,7 +48,7 @@ export default function Layout() {
};
const shouldHideNoteCreator = useMemo(() => {
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/p/", "/e"];
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/p/", "/e", "/subscribe"];
return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
}, [location, isReplyNoteCreatorShowing]);
@ -172,8 +175,15 @@ export default function Layout() {
{!shouldHideHeader && (
<header>
<div className="logo" onClick={() => navigate("/")}>
Snort
<h1>Snort</h1>
{subscription && (
<small className="flex">
<Icon name="diamond" size={10} className="mr5" />
{mapPlanName(subscription.type)}
</small>
)}
</div>
<div>
{publicKey ? (
<AccountHeader />

View File

@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { ApiHost } from "Const";
import Logo from "Element/Logo";
@ -8,12 +9,10 @@ import AsyncButton from "Element/AsyncButton";
import FollowListBase from "Element/FollowListBase";
import { RootState } from "State/Store";
import { bech32ToHex } from "Util";
import { useNavigate } from "react-router-dom";
import SnortApi from "SnortApi";
import messages from "./messages";
const TwitterFollowsApi = `${ApiHost}/api/v1/twitter/follows-for-nostr`;
export default function ImportFollows() {
const navigate = useNavigate();
const currentFollows = useSelector((s: RootState) => s.login.follows);
@ -21,6 +20,7 @@ export default function ImportFollows() {
const [twitterUsername, setTwitterUsername] = useState<string>("");
const [follows, setFollows] = useState<string[]>([]);
const [error, setError] = useState<string>("");
const api = new SnortApi(ApiHost);
const sortedTwitterFollows = useMemo(() => {
return follows.map(a => bech32ToHex(a)).sort(a => (currentFollows.includes(a) ? 1 : -1));
@ -30,22 +30,19 @@ export default function ImportFollows() {
setFollows([]);
setError("");
try {
const rsp = await fetch(`${TwitterFollowsApi}?username=${twitterUsername}`);
const data = await rsp.json();
if (rsp.ok) {
if (Array.isArray(data) && data.length === 0) {
setError(formatMessage(messages.NoUsersFound, { twitterUsername }));
} else {
setFollows(data);
}
} else if ("error" in data) {
setError(data.error);
const rsp = await api.twitterImport(twitterUsername);
if (Array.isArray(rsp) && rsp.length === 0) {
setError(formatMessage(messages.NoUsersFound, { twitterUsername }));
} else {
setError(formatMessage(messages.FailedToLoad));
setFollows(rsp);
}
} catch (e) {
console.warn(e);
setError(formatMessage(messages.FailedToLoad));
if (e instanceof Error) {
setError(e.message);
} else {
setError(formatMessage(messages.FailedToLoad));
}
}
}

View File

@ -49,7 +49,12 @@ const SettingsIndex = () => {
</div>
<div className="settings-row" onClick={() => navigate("handle")}>
<Icon name="badge" />
<FormattedMessage defaultMessage="Manage Nostr Adddress (NIP-05)" />
<FormattedMessage defaultMessage="Snort Nostr Adddress" />
<Icon name="arrowFront" />
</div>
<div className="settings-row" onClick={() => navigate("/subscribe/manage")}>
<Icon name="diamond" />
<FormattedMessage defaultMessage="Snort Subscription" />
<Icon name="arrowFront" />
</div>
<div className="settings-row" onClick={handleLogout}>

View File

@ -0,0 +1,123 @@
import { useEffect, useState } from "react";
import { FormattedDate, FormattedMessage, FormattedNumber } 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";
export default function ManageSubscriptionPage() {
const publisher = useEventPublisher();
const api = new SnortApi(undefined, publisher);
const [subs, setSubs] = useState<Array<Subscription>>();
const [error, setError] = useState("");
useEffect(() => {
(async () => {
try {
const s = await api.listSubscriptions();
setSubs(s);
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("Unknown error");
}
}
})();
}, []);
if (subs === undefined) {
return <PageSpinner />;
}
return (
<>
<h2>
<FormattedMessage defaultMessage="Subscriptions" />
</h2>
{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 (
<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 && (
<p>
<FormattedMessage
defaultMessage="It looks like you dont have any subscriptions, you can get one {link}"
values={{
link: (
<Link to="/subscribe">
<FormattedMessage defaultMessage="here" />
</Link>
),
}}
/>
</p>
)}
{error && <b className="error">{error}</b>}
</>
);
}

View File

@ -0,0 +1,24 @@
.subscribe-page > div.card {
margin: 5px;
min-height: 350px;
user-select: none;
flex: 1;
}
.subscribe-page h2 {
text-align: center;
}
.subscribe-page ul {
padding-inline-start: 20px;
}
@media (max-width: 720px) {
.subscribe-page {
flex-direction: column;
}
.subscribe-page > div.card {
flex: unset;
}
}

View File

@ -0,0 +1,113 @@
import "./index.css";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { RouteObject } from "react-router-dom";
import { formatShort } from "Number";
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 SendSats from "Element/SendSats";
export function mapPlanName(id: number) {
switch (id) {
case SubscriptionType.Supporter:
return <FormattedMessage defaultMessage="Supporter" />;
case SubscriptionType.Premium:
return <FormattedMessage defaultMessage="Premium" />;
}
}
export function mapFeatureName(k: LockedFeatures) {
switch (k) {
case LockedFeatures.MultiAccount:
return <FormattedMessage defaultMessage="Multi account support" />;
case LockedFeatures.NostrAddress:
return <FormattedMessage defaultMessage="Snort nostr address" />;
case LockedFeatures.Badge:
return <FormattedMessage defaultMessage="Supporter Badge" />;
case LockedFeatures.DeepL:
return <FormattedMessage defaultMessage="DeepL translations" />;
case LockedFeatures.RelayRetention:
return <FormattedMessage defaultMessage="Unlimited note retention on Snort relay" />;
case LockedFeatures.RelayBackup:
return <FormattedMessage defaultMessage="Downloadable backups from Snort relay" />;
}
}
export function SubscribePage() {
const publisher = useEventPublisher();
const api = new SnortApi(undefined, publisher);
const [invoice, setInvoice] = useState("");
async function subscribe(type: number) {
const rsp = await api.createSubscription(type);
setInvoice(rsp.pr);
}
return (
<div className="flex subscribe-page">
{Plans.map(a => {
const lower = Plans.filter(b => b.id < a.id);
return (
<div className={`card flex f-col${a.disabled ? " disabled" : ""}`}>
<div className="f-grow">
<h2>{mapPlanName(a.id)}</h2>
<p>
<FormattedMessage
defaultMessage="Support Snort every month for {price} sats and receive the following rewards"
values={{
price: <b>{formatShort(a.price)}</b>,
}}
/>
:
</p>
<ul>
{a.unlocks.map(b => (
<li>{mapFeatureName(b)}</li>
))}
{lower.map(b => (
<li>
<FormattedMessage
defaultMessage="Everything in {plan}"
values={{
plan: mapPlanName(b.id),
}}
/>
</li>
))}
</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 for {amount}/mo"
values={{ amount: formatShort(a.price) }}
/>
)}
</AsyncButton>
</div>
</div>
);
})}
<SendSats invoice={invoice} show={invoice !== ""} onClose={() => setInvoice("")} />
</div>
);
}
export const SubscribeRoutes = [
{
path: "/subscribe",
element: <SubscribePage />,
},
{
path: "/subscribe/manage",
element: <ManageSubscriptionPage />,
},
] as RouteObject[];