Add support bar and donation modal to homepage

Introduces a support bar component on the homepage to display project funding progress and encourage donations. Adds a modal with more information about supporting the project. Updates configuration to allow toggling the support bar and setting funding values. Updates links to the new donation page and adds related translations.
This commit is contained in:
Pas 2025-12-09 12:04:46 -07:00
parent dbf2c02a53
commit f2b39b046c
9 changed files with 199 additions and 3 deletions

View file

@ -257,7 +257,7 @@ It will improve uptime for FED API and faster EU streams, another proxy which we
If you are interested in donating, please check the link below!
</description>
<link>https://rentry.co/h5mypdfs</link>
<link>https://rentry.co/nnqtas3e</link>
<pubDate>Sat, 06 Sep 2025 14:42:00 MST</pubDate>
<category>announcement</category>
</item>

View file

@ -392,6 +392,18 @@
"It's the Great Pumpkin, Charlie Brown!"
]
}
},
"support": {
"title": "P-Stream needs your help!",
"description": "P-Stream is run at a loss, and we need help to keep it ad free! If you enjoy using P-Stream, please consider donating to help us cover our costs.",
"moreInfo": "More info",
"explanation": "If you aren't using the extension or don't have FED API set up, it may be harder to find content! We want to fix this, but it's a lot harder to provide content without expensive servers. So please, if you enjoy using P-Stream, please consider donating to help us cover our growing costs.",
"explanation2": "If you want more info, please join our ",
"discord": "Discord",
"thankYou": "Thank you for your support!",
"donate": "Donate",
"label": "Project Funding: ${{current}} / ${{goal}}",
"complete": "complete"
}
},
"media": {

View file

@ -315,7 +315,7 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
/>
<CircleDropdownLink href="/support" icon={Icons.SUPPORT} />
<CircleDropdownLink
href="https://rentry.co/h5mypdfs"
href="https://rentry.co/nnqtas3e"
icon={Icons.TIP_JAR}
/>
</div>

View file

@ -83,7 +83,7 @@ export function Footer() {
<FooterLink icon={Icons.DISCORD} href={conf().DISCORD_LINK}>
{t("footer.links.discord")}
</FooterLink>
<FooterLink href="https://rentry.co/h5mypdfs" icon={Icons.TIP_JAR}>
<FooterLink href="https://rentry.co/nnqtas3e" icon={Icons.TIP_JAR}>
{t("footer.links.funding")}
</FooterLink>
<div className="inline md:hidden">

View file

@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";
import { FancyModal } from "./Modal";
import { Button } from "../buttons/Button";
import { MwLink } from "../text/Link";
export function SupportInfoModal({ id }: { id: string }) {
const { t } = useTranslation();
return (
<FancyModal id={id} title={t("home.support.title")} size="md">
<div className="space-y-4">
<p className="text-type-secondary">{t("home.support.explanation")}</p>
<p className="text-type-secondary">
{t("home.support.explanation2")}{" "}
<MwLink url="https://discord.gg/7z6znYgrTG">
{t("home.support.discord")}
</MwLink>
</p>
<div className="space-y-3">
<span className="text-center flex justify-center whitespace-nowrap items-center">
<Button
theme="purple"
onClick={() =>
window.open("https://rentry.co/nnqtas3e", "_blank")
}
>
{t("home.support.donate")}
</Button>
</span>
</div>
<div className="text-xs text-type-dimmed text-center">
{t("home.support.thankYou")}
</div>
</div>
</FancyModal>
);
}

View file

@ -25,6 +25,7 @@ import { MediaItem } from "@/utils/mediaTypes";
import { Button } from "./About";
import { AdsPart } from "./parts/home/AdsPart";
import { SupportBar } from "./parts/home/SupportBar";
function useSearch(search: string) {
const [searching, setSearching] = useState<boolean>(false);
@ -171,6 +172,8 @@ export function HomePage() {
/>
)}
{conf().SHOW_SUPPORT_BAR ? <SupportBar /> : null}
{conf().SHOW_AD ? <AdsPart /> : null}
</div>

View file

@ -0,0 +1,131 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
import { SettingsCard } from "@/components/layout/SettingsCard";
import { MwLink } from "@/components/text/Link";
import { Heading3 } from "@/components/utils/Text";
import { conf } from "@/setup/config";
import { useOverlayStack } from "@/stores/interface/overlayStack";
function getCookie(name: string): string | null {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i += 1) {
const cookie = cookies[i].trim();
if (cookie.startsWith(`${name}=`)) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function setCookie(name: string, value: string, expiryDays: number): void {
const date = new Date();
date.setTime(date.getTime() + expiryDays * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/`;
}
export function SupportBar() {
const { t } = useTranslation();
const { showModal } = useOverlayStack();
const [isDescriptionDismissed, setIsDescriptionDismissed] = useState(() => {
return getCookie("supportDescriptionDismissed") === "true";
});
const toggleDescription = useCallback(() => {
const newState = !isDescriptionDismissed;
setIsDescriptionDismissed(newState);
setCookie("supportDescriptionDismissed", newState ? "true" : "false", 14); // Expires after 14 days
}, [isDescriptionDismissed]);
const openSupportModal = useCallback(() => {
showModal("support-info");
}, [showModal]);
const supportValue = conf().SUPPORT_BAR_VALUE;
if (!supportValue) return null;
// Parse fraction like "100/300"
const [currentStr, goalStr] = supportValue.split("/");
const current = parseFloat(currentStr) || 0;
const goal = parseFloat(goalStr) || 1;
const percentage = Math.min((current / goal) * 100, 100);
return (
<div className="w-full px-4 py-2">
<div className="flex flex-col items-center space-y-2">
<SettingsCard className="max-w-md relative group">
<button
onClick={toggleDescription}
type="button"
className="absolute z-20 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label={
isDescriptionDismissed ? "Show description" : "Hide description"
}
>
<Icon
className="text-s font-semibold text-type-secondary"
icon={
isDescriptionDismissed ? Icons.CHEVRON_UP : Icons.CHEVRON_DOWN
}
/>
</button>
<div
className={`overflow-hidden transition-all duration-300 ${
isDescriptionDismissed
? "max-h-0 opacity-0 pb-0"
: "max-h-32 opacity-100 pb-4"
}`}
>
<Heading3 className="transition-opacity duration-300">
{t("home.support.title")}
</Heading3>
<p className="text-type-secondary max-w-md pb-4 transition-opacity duration-300">
{t("home.support.description")}
</p>
</div>
<div className="flex flex-grow items-center text-sm text-type-dimmed w-full max-w-md pb-4">
<span className="text-left">
{t("home.support.label", {
current: current.toLocaleString(),
goal: goal.toLocaleString(),
})}
</span>
<span className="ml-auto text-right flex-shrink-0 whitespace-nowrap">
{percentage.toFixed(1)}% {t("home.support.complete")}
</span>
</div>
<div className="w-full max-w-md">
<div className="relative w-full h-2 bg-progress-background bg-opacity-25 rounded-full">
{/* Progress bar */}
<div
className="absolute top-0 left-0 h-full rounded-full bg-progress-filled transition-all duration-300"
style={{
width: `${percentage}%`,
}}
/>
</div>
</div>
<div className="flex flex-grow items-center text-sm text-type-dimmed w-full max-w-md pt-4">
<span className="text-left">
<button
type="button"
onClick={openSupportModal}
className="group mt-1 cursor-pointer font-bold text-type-link hover:text-type-linkHover active:scale-95"
>
{t("home.support.moreInfo")}
</button>
</span>
<span className="ml-auto text-right flex-shrink-0 whitespace-nowrap">
<MwLink url="https://rentry.co/nnqtas3e">
{t("home.support.donate")}
</MwLink>
</span>
</div>
</SettingsCard>
</div>
</div>
);
}

View file

@ -14,6 +14,7 @@ import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
import { DetailsModal } from "@/components/overlays/detailsModal";
import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal";
import { NotificationModal } from "@/components/overlays/notificationsModal";
import { SupportInfoModal } from "@/components/overlays/SupportInfoModal";
import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents";
import { useOnlineListener } from "@/hooks/usePing";
import { AboutPage } from "@/pages/About";
@ -126,6 +127,7 @@ function App() {
<LanguageProvider />
<NotificationModal id="notifications" />
<KeyboardCommandsModal id="keyboard-commands" />
<SupportInfoModal id="support-info" />
<DetailsModal id="details" />
<DetailsModal id="discover-details" />
<DetailsModal id="player-details" />

View file

@ -34,6 +34,8 @@ interface Config {
BANNER_ID: string;
USE_TRAKT: boolean;
HIDE_PROXY_ONBOARDING: boolean;
SHOW_SUPPORT_BAR: boolean;
SUPPORT_BAR_VALUE: string;
}
export interface RuntimeConfig {
@ -64,6 +66,8 @@ export interface RuntimeConfig {
BANNER_ID: string | null;
USE_TRAKT: boolean;
HIDE_PROXY_ONBOARDING: boolean;
SHOW_SUPPORT_BAR: boolean;
SUPPORT_BAR_VALUE: string;
}
const env: Record<keyof Config, undefined | string> = {
@ -97,6 +101,8 @@ const env: Record<keyof Config, undefined | string> = {
BANNER_ID: import.meta.env.VITE_BANNER_ID,
USE_TRAKT: import.meta.env.VITE_USE_TRAKT,
HIDE_PROXY_ONBOARDING: import.meta.env.VITE_HIDE_PROXY_ONBOARDING,
SHOW_SUPPORT_BAR: import.meta.env.VITE_SHOW_SUPPORT_BAR,
SUPPORT_BAR_VALUE: import.meta.env.VITE_SUPPORT_BAR_VALUE,
};
function coerceUndefined(value: string | null | undefined): string | undefined {
@ -173,5 +179,7 @@ export function conf(): RuntimeConfig {
BANNER_ID: getKey("BANNER_ID"),
USE_TRAKT: getKey("USE_TRAKT", "false") === "true",
HIDE_PROXY_ONBOARDING: getKey("HIDE_PROXY_ONBOARDING", "false") === "true",
SHOW_SUPPORT_BAR: getKey("SHOW_SUPPORT_BAR", "false") === "true",
SUPPORT_BAR_VALUE: getKey("SUPPORT_BAR_VALUE") ?? "",
};
}