diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index e9216ad..fb8679b 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -986,6 +986,10 @@ export interface components { updated_at: string; /** @description User biography with markdown, HTML, and plain text renderings */ bio?: components["schemas"]["MarkdownContent"] | null; + /** @description Denotes plan or other distinction */ + badge: components["schemas"]["UserBadge"] | null; + /** @description User's subscription tier */ + tier: components["schemas"]["UserTier"]; counts: components["schemas"]["UserCounts"]; /** @description HATEOAS links for navigation */ _links: components["schemas"]["Links"]; @@ -1043,6 +1047,15 @@ export interface components { */ users: number; }; + /** + * @description Denotes plan or other distinction: + * - `staff`: Are.na staff member + * - `investor`: Investor + * - `supporter`: Supporter subscriber + * - `premium`: Premium subscriber + * @enum {string} + */ + UserBadge: "staff" | "investor" | "supporter" | "premium"; /** * @description User subscription tier: * - `guest`: Unauthenticated user diff --git a/src/api/types.ts b/src/api/types.ts index b760af9..7855fbf 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -34,6 +34,7 @@ export type ConnectionSort = Schemas["ConnectionSort"]; export type ChannelContentSort = Schemas["ChannelContentSort"]; export type ContentSort = Schemas["ContentSort"]; export type ConnectionFilter = Schemas["ConnectionFilter"]; +export type UserTier = Schemas["UserTier"]; export type PresignedFile = Schemas["PresignedFile"]; export type PaginationMeta = Schemas["PaginationMeta"]; diff --git a/src/commands/search.tsx b/src/commands/search.tsx index 10ad1a6..2df3a2a 100644 --- a/src/commands/search.tsx +++ b/src/commands/search.tsx @@ -13,6 +13,9 @@ import { BlockItem } from "../components/BlockItem"; import { Spinner } from "../components/Spinner"; import { useCommand } from "../hooks/use-command"; import { plural } from "../lib/format"; +import { openUrl } from "../lib/open"; + +const BILLING_URL = "https://www.are.na/billing"; interface Props { query: string; @@ -69,11 +72,23 @@ export function SearchCommand({ if (loading) return ; if (error) { + const isPremiumError = error.includes("Forbidden"); + + if (isPremiumError) { + openUrl(BILLING_URL); + } + return ( ✕ {error} - {error.includes("Forbidden") && ( - Search requires an Are.na Premium subscription + {isPremiumError && ( + <> + + {" "} + Search requires an Are.na Premium subscription + + Opening {BILLING_URL} + )} ); diff --git a/src/commands/whoami.tsx b/src/commands/whoami.tsx index 664b66f..179c4e1 100644 --- a/src/commands/whoami.tsx +++ b/src/commands/whoami.tsx @@ -2,7 +2,7 @@ import { Box, Text } from "ink"; import { arenaApiBaseUrl, client, getData } from "../api/client"; import { Spinner } from "../components/Spinner"; import { useCommand } from "../hooks/use-command"; -import { plural } from "../lib/format"; +import { formatTier, plural } from "../lib/format"; export function WhoamiCommand() { const { data, error, loading } = useCommand(() => @@ -15,16 +15,30 @@ export function WhoamiCommand() { const stats = [ plural(data.counts.channels, "channel"), - `${data.counts.following.toLocaleString()} following`, plural(data.counts.followers, "follower"), ] .filter(Boolean) .join(" · "); + const tierLabel = formatTier(data.tier); + const tierColor = + data.tier === "premium" + ? "green" + : data.tier === "supporter" + ? "cyan" + : undefined; + return ( - {data.name} + + {data.name} + {tierColor ? ( + [{tierLabel}] + ) : ( + [{tierLabel}] + )} + @{data.slug} {stats && {stats}} API: {arenaApiBaseUrl} diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx index 50a6036..22b0ce8 100644 --- a/src/components/SearchResults.tsx +++ b/src/components/SearchResults.tsx @@ -1,4 +1,5 @@ -import { Box, Text } from "ink"; +import { Box, Text, useInput } from "ink"; +import { useState } from "react"; import useSWR from "swr"; import { ArenaError, client, getData } from "../api/client"; import type { @@ -16,7 +17,9 @@ import { accentColor, mutedColor } from "../lib/theme"; import { BlockItem } from "./BlockItem"; import { useSessionPaletteActive } from "./SessionPaletteContext"; import { Panel, ScreenFrame } from "./ScreenChrome"; -import { ScreenEmpty, ScreenError, ScreenLoading } from "./ScreenStates"; +import { ScreenEmpty, ScreenLoading } from "./ScreenStates"; + +const BILLING_URL = "https://www.are.na/billing"; type SearchNavigateView = | { kind: "channel"; slug: string } @@ -133,10 +136,18 @@ export function SearchResults({ if (error) { const isPermission = error instanceof ArenaError && error.status === 403; - const message = isPermission - ? "Search requires Are.na Premium" - : error.message; - return ; + + if (isPermission) { + return ; + } + + return ( + + + ✕ {error.message} + + + ); } if (items.length === 0) { @@ -189,3 +200,28 @@ export function SearchResults({ ); } + +function SearchPremiumGate({ onBack }: { onBack: () => void }) { + const paletteActive = useSessionPaletteActive(); + const [opened, setOpened] = useState(false); + + useInput((input, key) => { + if (paletteActive) return; + if (input === "q" || key.escape) return onBack(); + if (input === "o" && !opened) { + openUrl(BILLING_URL); + setOpened(true); + } + }); + + return ( + + + Search requires an Are.na Premium subscription + + {opened ? `Opened ${BILLING_URL}` : "Press o to open billing page"} + + + + ); +} diff --git a/src/components/WhoamiScreen.tsx b/src/components/WhoamiScreen.tsx index eb099fe..9d6e4fa 100644 --- a/src/components/WhoamiScreen.tsx +++ b/src/components/WhoamiScreen.tsx @@ -1,5 +1,5 @@ import type { User } from "../api/types"; -import { plural } from "../lib/format"; +import { formatTier, plural } from "../lib/format"; import { EntityProfileScreen } from "./EntityProfileScreen"; export function WhoamiScreen({ me, onBack }: { me: User; onBack: () => void }) { @@ -8,6 +8,7 @@ export function WhoamiScreen({ me, onBack }: { me: User; onBack: () => void }) { name={me.name} slug={me.slug} statsLine={`${plural(me.counts.channels, "channel")} · ${me.counts.following.toLocaleString()} following · ${plural(me.counts.followers, "follower")}`} + metaLine={formatTier(me.tier)} browserUrl={`https://www.are.na/${me.slug}`} onBack={onBack} /> diff --git a/src/lib/format.ts b/src/lib/format.ts index 800cd16..8447eee 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,4 +1,4 @@ -import type { Followable } from "../api/types"; +import type { Followable, UserTier } from "../api/types"; export function timeAgo(dateStr: string): string { const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); @@ -66,6 +66,21 @@ export function formatFollowable(item: Followable): string { } } +const TIER_LABELS: Record = { + premium: "Premium", + supporter: "Supporter", + free: "Free", + guest: "Guest", +}; + +export function formatTier(tier: UserTier): string { + return TIER_LABELS[tier] ?? tier; +} + +export function isPremium(tier: UserTier): boolean { + return tier === "premium" || tier === "supporter"; +} + export function formatFileSize(bytes?: number | null): string | null { if (!bytes || bytes <= 0) return null; if (bytes < 1024) return `${bytes} B`;