mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
feat(web): update sponsors logic
This commit is contained in:
@@ -11,60 +11,29 @@ import {
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
calculateLifetimeContribution,
|
||||
filterCurrentSponsors,
|
||||
filterPastSponsors,
|
||||
filterSpecialSponsors,
|
||||
filterVisibleSponsors,
|
||||
formatSponsorUrl,
|
||||
getSponsorUrl,
|
||||
isSpecialSponsor,
|
||||
shouldShowLifetimeTotal,
|
||||
sortSpecialSponsors,
|
||||
sortSponsors,
|
||||
} from "@/lib/sponsor-utils";
|
||||
|
||||
type SponsorEntry = {
|
||||
sponsor: {
|
||||
login: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
websiteUrl?: string;
|
||||
linkUrl: string;
|
||||
customLogoUrl?: string;
|
||||
type: string;
|
||||
};
|
||||
isOneTime: boolean;
|
||||
monthlyDollars: number;
|
||||
privacyLevel: string;
|
||||
tierName: string;
|
||||
createdAt: string;
|
||||
provider: string;
|
||||
};
|
||||
import type { SponsorsData } from "@/lib/types";
|
||||
|
||||
export default function SponsorsSection({
|
||||
sponsors,
|
||||
sponsorsData,
|
||||
}: {
|
||||
sponsors: Array<SponsorEntry>;
|
||||
sponsorsData: SponsorsData;
|
||||
}) {
|
||||
const [showPastSponsors, setShowPastSponsors] = useState(false);
|
||||
|
||||
const sponsorsData =
|
||||
sponsors.map((sponsor) => ({
|
||||
...sponsor,
|
||||
sponsor: {
|
||||
...sponsor.sponsor,
|
||||
customLogoUrl: sponsor.sponsor.customLogoUrl || "",
|
||||
},
|
||||
})) || [];
|
||||
|
||||
const visibleSponsors = filterVisibleSponsors(sponsorsData);
|
||||
const sortedSponsors = sortSponsors(visibleSponsors);
|
||||
const currentSponsors = filterCurrentSponsors(sortedSponsors);
|
||||
const pastSponsors = filterPastSponsors(sortSponsors(sponsorsData));
|
||||
const specialSponsors = sortSpecialSponsors(
|
||||
filterSpecialSponsors(currentSponsors),
|
||||
);
|
||||
const allCurrentSponsors = [
|
||||
...sponsorsData.specialSponsors,
|
||||
...sponsorsData.sponsors,
|
||||
];
|
||||
const visibleSponsors = filterVisibleSponsors(allCurrentSponsors);
|
||||
const pastSponsors = sponsorsData.pastSponsors;
|
||||
const specialSponsors = sortSpecialSponsors(sponsorsData.specialSponsors);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
@@ -117,15 +86,11 @@ export default function SponsorsSection({
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{specialSponsors.map((entry, index) => {
|
||||
const since = new Date(entry.createdAt).toLocaleDateString(
|
||||
undefined,
|
||||
{ year: "numeric", month: "short" },
|
||||
);
|
||||
const sponsorUrl = getSponsorUrl(entry);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.sponsor.login}
|
||||
key={entry.githubId}
|
||||
className="rounded border border-border"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
@@ -135,7 +100,7 @@ export default function SponsorsSection({
|
||||
<div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span>SPECIAL</span>
|
||||
<span>•</span>
|
||||
<span>SINCE {since.toUpperCase()}</span>
|
||||
<span>{entry.sinceWhen.toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,11 +108,8 @@ export default function SponsorsSection({
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
src={
|
||||
entry.sponsor.customLogoUrl ||
|
||||
entry.sponsor.avatarUrl
|
||||
}
|
||||
alt={entry.sponsor.name || entry.sponsor.login}
|
||||
src={entry.avatarUrl}
|
||||
alt={entry.name}
|
||||
width={100}
|
||||
height={100}
|
||||
className="rounded border border-border transition-colors duration-300"
|
||||
@@ -157,33 +119,38 @@ export default function SponsorsSection({
|
||||
<div className="grid grid-cols-1 grid-rows-[1fr_auto] justify-between py-2">
|
||||
<div>
|
||||
<h3 className="truncate font-semibold text-foreground text-sm">
|
||||
{entry.sponsor.name || entry.sponsor.login}
|
||||
{entry.name}
|
||||
</h3>
|
||||
{entry.tierName && (
|
||||
{shouldShowLifetimeTotal(entry) ? (
|
||||
<>
|
||||
{entry.tierName && (
|
||||
<p className="text-primary text-xs">
|
||||
{entry.tierName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Total: {entry.formattedAmount}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-primary text-xs">
|
||||
{entry.tierName}
|
||||
</p>
|
||||
)}
|
||||
{shouldShowLifetimeTotal(entry) && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Total: ${calculateLifetimeContribution(entry)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<a
|
||||
href={`https://github.com/${entry.sponsor.login}`}
|
||||
href={entry.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 text-muted-foreground text-xs transition-colors hover:text-primary"
|
||||
>
|
||||
<Github className="size-3" />
|
||||
<span className="truncate">
|
||||
{entry.sponsor.login}
|
||||
{entry.githubId}
|
||||
</span>
|
||||
</a>
|
||||
{(entry.sponsor.websiteUrl ||
|
||||
entry.sponsor.linkUrl) && (
|
||||
{entry.websiteUrl && (
|
||||
<a
|
||||
href={sponsorUrl}
|
||||
target="_blank"
|
||||
@@ -207,98 +174,91 @@ export default function SponsorsSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentSponsors.filter((s) => !isSpecialSponsor(s)).length > 0 && (
|
||||
{sponsorsData.sponsors.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{currentSponsors
|
||||
.filter((s) => !isSpecialSponsor(s))
|
||||
.map((entry, index) => {
|
||||
const since = new Date(entry.createdAt).toLocaleDateString(
|
||||
undefined,
|
||||
{ year: "numeric", month: "short" },
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={entry.sponsor.login}
|
||||
className="rounded border border-border"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<div className="border-border border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary text-xs">▶</span>
|
||||
<div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span>SINCE {since.toUpperCase()}</span>
|
||||
</div>
|
||||
{sponsorsData.sponsors.map((entry, index) => {
|
||||
const sponsorUrl = getSponsorUrl(entry);
|
||||
return (
|
||||
<div
|
||||
key={entry.githubId}
|
||||
className="rounded border border-border"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<div className="border-border border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary text-xs">▶</span>
|
||||
<div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span>{entry.sinceWhen.toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
src={entry.sponsor.avatarUrl}
|
||||
alt={entry.sponsor.name || entry.sponsor.login}
|
||||
width={100}
|
||||
height={100}
|
||||
className="rounded border border-border transition-colors duration-300"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 grid-rows-[1fr_auto] justify-between py-2">
|
||||
<div>
|
||||
<h3 className="truncate font-semibold text-foreground text-sm">
|
||||
{entry.sponsor.name || entry.sponsor.login}
|
||||
</h3>
|
||||
{entry.tierName && (
|
||||
<p className="text-primary text-xs">
|
||||
{entry.tierName}
|
||||
</p>
|
||||
)}
|
||||
{shouldShowLifetimeTotal(entry) && (
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
src={entry.avatarUrl}
|
||||
alt={entry.name}
|
||||
width={100}
|
||||
height={100}
|
||||
className="rounded border border-border transition-colors duration-300"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 grid-rows-[1fr_auto] justify-between py-2">
|
||||
<div>
|
||||
<h3 className="truncate font-semibold text-foreground text-sm">
|
||||
{entry.name}
|
||||
</h3>
|
||||
{shouldShowLifetimeTotal(entry) ? (
|
||||
<>
|
||||
{entry.tierName && (
|
||||
<p className="text-primary text-xs">
|
||||
{entry.tierName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Total: $
|
||||
{calculateLifetimeContribution(entry)}
|
||||
Total: {entry.formattedAmount}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
</>
|
||||
) : (
|
||||
<p className="text-primary text-xs">
|
||||
{entry.tierName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<a
|
||||
href={entry.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 text-muted-foreground text-xs transition-colors hover:text-primary"
|
||||
>
|
||||
<Github className="size-3" />
|
||||
<span className="truncate">
|
||||
{entry.githubId}
|
||||
</span>
|
||||
</a>
|
||||
{entry.websiteUrl && (
|
||||
<a
|
||||
href={`https://github.com/${entry.sponsor.login}`}
|
||||
href={sponsorUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 text-muted-foreground text-xs transition-colors hover:text-primary"
|
||||
>
|
||||
<Github className="size-3" />
|
||||
<Globe className="size-3" />
|
||||
<span className="truncate">
|
||||
{entry.sponsor.login}
|
||||
{formatSponsorUrl(sponsorUrl)}
|
||||
</span>
|
||||
</a>
|
||||
{(entry.sponsor.websiteUrl ||
|
||||
entry.sponsor.linkUrl) && (
|
||||
<a
|
||||
href={
|
||||
entry.sponsor.websiteUrl ||
|
||||
entry.sponsor.linkUrl
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 text-muted-foreground text-xs transition-colors hover:text-primary"
|
||||
>
|
||||
<Globe className="size-3" />
|
||||
<span className="truncate">
|
||||
{formatSponsorUrl(
|
||||
entry.sponsor.websiteUrl ||
|
||||
entry.sponsor.linkUrl,
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -330,16 +290,12 @@ export default function SponsorsSection({
|
||||
{showPastSponsors && (
|
||||
<div className="slide-in-from-top-2 grid animate-in grid-cols-1 gap-4 duration-300 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{pastSponsors.map((entry, index) => {
|
||||
const since = new Date(entry.createdAt).toLocaleDateString(
|
||||
undefined,
|
||||
{ year: "numeric", month: "short" },
|
||||
);
|
||||
const wasSpecial = isSpecialSponsor(entry);
|
||||
const sponsorUrl = getSponsorUrl(entry);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.sponsor.login}
|
||||
key={entry.githubId}
|
||||
className="rounded border border-border/70 bg-muted/20"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
@@ -355,7 +311,7 @@ export default function SponsorsSection({
|
||||
<div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
|
||||
{wasSpecial && <span>SPECIAL</span>}
|
||||
{wasSpecial && <span>•</span>}
|
||||
<span>SINCE {since.toUpperCase()}</span>
|
||||
<span>{entry.sinceWhen.toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,11 +319,8 @@ export default function SponsorsSection({
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
src={
|
||||
entry.sponsor.customLogoUrl ||
|
||||
entry.sponsor.avatarUrl
|
||||
}
|
||||
alt={entry.sponsor.name || entry.sponsor.login}
|
||||
src={entry.avatarUrl}
|
||||
alt={entry.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded border border-border/70"
|
||||
@@ -377,34 +330,38 @@ export default function SponsorsSection({
|
||||
<div className="grid grid-cols-1 grid-rows-[1fr_auto] justify-between">
|
||||
<div>
|
||||
<h3 className="truncate font-semibold text-muted-foreground text-sm">
|
||||
{entry.sponsor.name || entry.sponsor.login}
|
||||
{entry.name}
|
||||
</h3>
|
||||
{entry.tierName && (
|
||||
{shouldShowLifetimeTotal(entry) ? (
|
||||
<>
|
||||
{entry.tierName && (
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{entry.tierName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground/50 text-xs">
|
||||
Total: {entry.formattedAmount}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{entry.tierName}
|
||||
</p>
|
||||
)}
|
||||
{!entry.isOneTime && (
|
||||
<p className="text-muted-foreground/50 text-xs">
|
||||
Total: $
|
||||
{calculateLifetimeContribution(entry)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<a
|
||||
href={`https://github.com/${entry.sponsor.login}`}
|
||||
href={entry.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 text-muted-foreground/70 text-xs transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
<Github className="size-3" />
|
||||
<span className="truncate">
|
||||
{entry.sponsor.login}
|
||||
{entry.githubId}
|
||||
</span>
|
||||
</a>
|
||||
{(entry.sponsor.websiteUrl ||
|
||||
entry.sponsor.linkUrl) && (
|
||||
{entry.websiteUrl && (
|
||||
<a
|
||||
href={sponsorUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -2,6 +2,7 @@ export const dynamic = "force-static";
|
||||
|
||||
import { api } from "@better-t-stack/backend/convex/_generated/api";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { fetchSponsors } from "@/lib/sponsors";
|
||||
import CommandSection from "./_components/command-section";
|
||||
import Footer from "./_components/footer";
|
||||
import HeroSection from "./_components/hero-section";
|
||||
@@ -10,7 +11,7 @@ import StatsSection from "./_components/stats-section";
|
||||
import Testimonials from "./_components/testimonials";
|
||||
|
||||
export default async function HomePage() {
|
||||
const sponsors = await fetchQuery(api.sponsors.getSponsors);
|
||||
const sponsorsData = await fetchSponsors();
|
||||
const fetchedTweets = await fetchQuery(api.testimonials.getTweets);
|
||||
const fetchedVideos = await fetchQuery(api.testimonials.getVideos);
|
||||
const videos = fetchedVideos.map((v) => ({
|
||||
@@ -31,7 +32,7 @@ export default async function HomePage() {
|
||||
<HeroSection />
|
||||
<CommandSection />
|
||||
<StatsSection analyticsData={minimalAnalyticsData} />
|
||||
<SponsorsSection sponsors={sponsors} />
|
||||
<SponsorsSection sponsorsData={sponsorsData} />
|
||||
<Testimonials tweets={tweets} videos={videos} />
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
@@ -8,30 +8,24 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import {
|
||||
filterCurrentSponsors,
|
||||
filterSpecialSponsors,
|
||||
formatSponsorUrl,
|
||||
getSponsorUrl,
|
||||
sortSpecialSponsors,
|
||||
} from "@/lib/sponsor-utils";
|
||||
import type { Sponsor } from "@/lib/types";
|
||||
import type { Sponsor, SponsorsData } from "@/lib/types";
|
||||
|
||||
export function SpecialSponsorBanner() {
|
||||
const [specialSponsors, setSpecialSponsors] = useState<Sponsor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("https://sponsors.amanv.dev/sponsors.json")
|
||||
fetch("https://sponsors.better-t-stack.dev/sponsors.json")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to fetch sponsors");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const sponsorsData = Array.isArray(data) ? data : [];
|
||||
const currentSponsors = filterCurrentSponsors(sponsorsData);
|
||||
const specials = sortSpecialSponsors(
|
||||
filterSpecialSponsors(currentSponsors),
|
||||
);
|
||||
.then((data: SponsorsData) => {
|
||||
const specials = sortSpecialSponsors(data.specialSponsors);
|
||||
setSpecialSponsors(specials);
|
||||
setLoading(false);
|
||||
})
|
||||
@@ -63,30 +57,21 @@ export function SpecialSponsorBanner() {
|
||||
<div>
|
||||
<div className="no-scrollbar grid grid-cols-4 items-center gap-2 overflow-x-auto whitespace-nowrap py-1">
|
||||
{specialSponsors.map((entry) => {
|
||||
const displayName = entry.sponsor.name || entry.sponsor.login;
|
||||
const imgSrc = entry.sponsor.customLogoUrl || entry.sponsor.avatarUrl;
|
||||
const since = new Date(entry.createdAt).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
},
|
||||
);
|
||||
const sponsorUrl = getSponsorUrl(entry);
|
||||
|
||||
return (
|
||||
<HoverCard key={entry.sponsor.login}>
|
||||
<HoverCard key={entry.githubId}>
|
||||
<HoverCardTrigger asChild>
|
||||
<a
|
||||
href={entry.sponsor.websiteUrl || sponsorUrl}
|
||||
href={entry.websiteUrl || sponsorUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={displayName}
|
||||
aria-label={entry.name}
|
||||
className="inline-flex"
|
||||
>
|
||||
<Image
|
||||
src={imgSrc}
|
||||
alt={displayName}
|
||||
src={entry.avatarUrl}
|
||||
alt={entry.name}
|
||||
width={66}
|
||||
height={66}
|
||||
className="size-12 rounded border border-border"
|
||||
@@ -105,13 +90,13 @@ export function SpecialSponsorBanner() {
|
||||
<div className="ml-auto text-muted-foreground text-xs">
|
||||
<span>SPECIAL</span>
|
||||
<span className="px-1">•</span>
|
||||
<span>SINCE {since.toUpperCase()}</span>
|
||||
<span>{entry.sinceWhen.toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Image
|
||||
src={imgSrc}
|
||||
alt={displayName}
|
||||
src={entry.avatarUrl}
|
||||
alt={entry.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded border border-border"
|
||||
@@ -120,7 +105,7 @@ export function SpecialSponsorBanner() {
|
||||
<div className="grid grid-cols-1 grid-rows-[1fr_auto]">
|
||||
<div>
|
||||
<h3 className="truncate font-semibold text-sm">
|
||||
{displayName}
|
||||
{entry.name}
|
||||
</h3>
|
||||
{entry.tierName ? (
|
||||
<p className="text-primary text-xs">
|
||||
@@ -130,17 +115,15 @@ export function SpecialSponsorBanner() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<a
|
||||
href={`https://github.com/${entry.sponsor.login}`}
|
||||
href={entry.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 text-muted-foreground text-xs transition-colors hover:text-primary"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="truncate">
|
||||
{entry.sponsor.login}
|
||||
</span>
|
||||
<span className="truncate">{entry.githubId}</span>
|
||||
</a>
|
||||
{entry.sponsor.websiteUrl || entry.sponsor.linkUrl ? (
|
||||
{entry.websiteUrl ? (
|
||||
<a
|
||||
href={sponsorUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -3,67 +3,30 @@ import type { Sponsor } from "@/lib/types";
|
||||
export const SPECIAL_SPONSOR_THRESHOLD = 100;
|
||||
|
||||
export const getSponsorAmount = (sponsor: Sponsor): number => {
|
||||
// For past sponsors, return 0
|
||||
if (sponsor.monthlyDollars === -1) {
|
||||
return 0;
|
||||
// If totalProcessedAmount exists, use it, otherwise parse from tierName
|
||||
if (sponsor.totalProcessedAmount !== undefined) {
|
||||
return sponsor.totalProcessedAmount;
|
||||
}
|
||||
|
||||
// For one-time sponsors, parse the actual amount from tierName
|
||||
if (sponsor.isOneTime && sponsor.tierName) {
|
||||
const match = sponsor.tierName.match(/\$(\d+(?:\.\d+)?)/);
|
||||
return match ? Number.parseFloat(match[1]) : sponsor.monthlyDollars;
|
||||
}
|
||||
|
||||
// For monthly sponsors, use monthlyDollars
|
||||
return sponsor.monthlyDollars;
|
||||
// Parse amount from tierName as fallback
|
||||
const match = sponsor.tierName.match(/\$(\d+(?:\.\d+)?)/);
|
||||
return match ? Number.parseFloat(match[1]) : 0;
|
||||
};
|
||||
|
||||
export const calculateLifetimeContribution = (sponsor: Sponsor): number => {
|
||||
// For past sponsors, return 0
|
||||
if (sponsor.monthlyDollars === -1) {
|
||||
return 0;
|
||||
// If totalProcessedAmount exists, use it, otherwise parse from tierName
|
||||
if (sponsor.totalProcessedAmount !== undefined) {
|
||||
return sponsor.totalProcessedAmount;
|
||||
}
|
||||
|
||||
// For one-time sponsors, return the one-time amount
|
||||
if (sponsor.isOneTime && sponsor.tierName) {
|
||||
const match = sponsor.tierName.match(/\$(\d+(?:\.\d+)?)/);
|
||||
return match ? Number.parseFloat(match[1]) : 0;
|
||||
}
|
||||
|
||||
// For monthly sponsors, calculate total contribution since they started
|
||||
const startDate = new Date(sponsor.createdAt);
|
||||
const currentDate = new Date();
|
||||
const monthsSinceStart = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(currentDate.getTime() - startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24 * 30.44),
|
||||
),
|
||||
);
|
||||
|
||||
return sponsor.monthlyDollars * monthsSinceStart;
|
||||
// Parse amount from tierName as fallback
|
||||
const match = sponsor.tierName.match(/\$(\d+(?:\.\d+)?)/);
|
||||
return match ? Number.parseFloat(match[1]) : 0;
|
||||
};
|
||||
|
||||
export const shouldShowLifetimeTotal = (sponsor: Sponsor): boolean => {
|
||||
// Don't show for past sponsors
|
||||
if (sponsor.monthlyDollars === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show for one-time sponsors
|
||||
if (sponsor.isOneTime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show for first month sponsors
|
||||
const startDate = new Date(sponsor.createdAt);
|
||||
const currentDate = new Date();
|
||||
const monthsSinceStart = Math.floor(
|
||||
(currentDate.getTime() - startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24 * 30.44),
|
||||
);
|
||||
|
||||
return monthsSinceStart > 1;
|
||||
// Only show lifetime total if totalProcessedAmount exists
|
||||
return sponsor.totalProcessedAmount !== undefined;
|
||||
};
|
||||
|
||||
export const filterVisibleSponsors = (sponsors: Sponsor[]): Sponsor[] => {
|
||||
@@ -87,69 +50,27 @@ export const sortSponsors = (sponsors: Sponsor[]): Sponsor[] => {
|
||||
return sponsors.sort((a, b) => {
|
||||
const aAmount = getSponsorAmount(a);
|
||||
const bAmount = getSponsorAmount(b);
|
||||
const aLifetime = calculateLifetimeContribution(a);
|
||||
const bLifetime = calculateLifetimeContribution(b);
|
||||
const aIsPast = a.monthlyDollars === -1;
|
||||
const bIsPast = b.monthlyDollars === -1;
|
||||
const aIsSpecial = isSpecialSponsor(a);
|
||||
const bIsSpecial = isSpecialSponsor(b);
|
||||
const aIsLifetimeSpecial = isLifetimeSpecialSponsor(a);
|
||||
const bIsLifetimeSpecial = isLifetimeSpecialSponsor(b);
|
||||
|
||||
// 1. Special sponsors (>=$100 current) come first
|
||||
// 1. Special sponsors (>=$100) come first
|
||||
if (aIsSpecial && !bIsSpecial) return -1;
|
||||
if (!aIsSpecial && bIsSpecial) return 1;
|
||||
if (aIsSpecial && bIsSpecial) {
|
||||
if (aAmount !== bAmount) {
|
||||
return bAmount - aAmount;
|
||||
}
|
||||
// If amounts equal, prefer monthly over one-time
|
||||
if (a.isOneTime && !b.isOneTime) return 1;
|
||||
if (!a.isOneTime && b.isOneTime) return -1;
|
||||
// Then by creation date (oldest first)
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
// If amounts equal, sort by name
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
// 2. Lifetime special sponsors (>=$100 total) come next
|
||||
if (aIsLifetimeSpecial && !bIsLifetimeSpecial) return -1;
|
||||
if (!aIsLifetimeSpecial && bIsLifetimeSpecial) return 1;
|
||||
if (aIsLifetimeSpecial && bIsLifetimeSpecial) {
|
||||
if (aLifetime !== bLifetime) {
|
||||
return bLifetime - aLifetime;
|
||||
}
|
||||
// If lifetime amounts equal, prefer monthly over one-time
|
||||
if (a.isOneTime && !b.isOneTime) return 1;
|
||||
if (!a.isOneTime && b.isOneTime) return -1;
|
||||
// Then by creation date (oldest first)
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
// 2. Regular sponsors sorted by amount (highest first)
|
||||
if (aAmount !== bAmount) {
|
||||
return bAmount - aAmount;
|
||||
}
|
||||
|
||||
// 3. Current sponsors come before past sponsors
|
||||
if (!aIsPast && bIsPast) return -1;
|
||||
if (aIsPast && !bIsPast) return 1;
|
||||
|
||||
// 4. For current sponsors, sort by lifetime contribution (highest first)
|
||||
if (!aIsPast && !bIsPast) {
|
||||
if (aLifetime !== bLifetime) {
|
||||
return bLifetime - aLifetime;
|
||||
}
|
||||
// If lifetime amounts equal, prefer monthly over one-time
|
||||
if (a.isOneTime && !b.isOneTime) return 1;
|
||||
if (!a.isOneTime && b.isOneTime) return -1;
|
||||
// Then by creation date (oldest first)
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
}
|
||||
|
||||
// 5. For past sponsors, sort by lifetime contribution (highest first)
|
||||
if (aIsPast && bIsPast) {
|
||||
if (aLifetime !== bLifetime) {
|
||||
return bLifetime - aLifetime;
|
||||
}
|
||||
// Then by creation date (newest first)
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
|
||||
return 0;
|
||||
// 3. If amounts equal, sort by name
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -158,33 +79,24 @@ export const sortSpecialSponsors = (sponsors: Sponsor[]): Sponsor[] => {
|
||||
const aLifetime = calculateLifetimeContribution(a);
|
||||
const bLifetime = calculateLifetimeContribution(b);
|
||||
|
||||
// First, prioritize current special sponsors
|
||||
const aIsSpecial = isSpecialSponsor(a);
|
||||
const bIsSpecial = isSpecialSponsor(b);
|
||||
|
||||
if (aIsSpecial && !bIsSpecial) return -1;
|
||||
if (!aIsSpecial && bIsSpecial) return 1;
|
||||
|
||||
// Then sort by lifetime contribution (highest first)
|
||||
// Sort by lifetime contribution (highest first)
|
||||
if (aLifetime !== bLifetime) {
|
||||
return bLifetime - aLifetime;
|
||||
}
|
||||
|
||||
// If lifetime amounts equal, prefer monthly over one-time
|
||||
if (a.isOneTime && !b.isOneTime) return 1;
|
||||
if (!a.isOneTime && b.isOneTime) return -1;
|
||||
|
||||
// Then by creation date (oldest first)
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
// If amounts equal, sort by name
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
export const filterCurrentSponsors = (sponsors: Sponsor[]): Sponsor[] => {
|
||||
return sponsors.filter((sponsor) => sponsor.monthlyDollars !== -1);
|
||||
// In the new structure, all sponsors in the main arrays are current
|
||||
return sponsors;
|
||||
};
|
||||
|
||||
export const filterPastSponsors = (sponsors: Sponsor[]): Sponsor[] => {
|
||||
return sponsors.filter((sponsor) => sponsor.monthlyDollars === -1);
|
||||
export const filterPastSponsors = (_sponsors: Sponsor[]): Sponsor[] => {
|
||||
// Past sponsors are handled separately in the new structure
|
||||
return [];
|
||||
};
|
||||
|
||||
export const filterSpecialSponsors = (sponsors: Sponsor[]): Sponsor[] => {
|
||||
@@ -196,11 +108,7 @@ export const filterRegularSponsors = (sponsors: Sponsor[]): Sponsor[] => {
|
||||
};
|
||||
|
||||
export const getSponsorUrl = (sponsor: Sponsor): string => {
|
||||
return (
|
||||
sponsor.sponsor.websiteUrl ||
|
||||
sponsor.sponsor.linkUrl ||
|
||||
`https://github.com/${sponsor.sponsor.login}`
|
||||
);
|
||||
return sponsor.websiteUrl || sponsor.githubUrl;
|
||||
};
|
||||
|
||||
export const formatSponsorUrl = (url: string): string => {
|
||||
|
||||
35
apps/web/src/lib/sponsors.ts
Normal file
35
apps/web/src/lib/sponsors.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { SponsorsData } from "./types";
|
||||
|
||||
const SPONSORS_URL = "https://sponsors.better-t-stack.dev/sponsors.json";
|
||||
|
||||
export async function fetchSponsors(): Promise<SponsorsData> {
|
||||
try {
|
||||
const response = await fetch(SPONSORS_URL, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch sponsors: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data as SponsorsData;
|
||||
} catch (error) {
|
||||
console.error("Error fetching sponsors:", error);
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
summary: {
|
||||
total_sponsors: 0,
|
||||
total_lifetime_amount: 0,
|
||||
total_current_monthly: 0,
|
||||
special_sponsors: 0,
|
||||
current_sponsors: 0,
|
||||
past_sponsors: 0,
|
||||
top_sponsor: { name: "", amount: 0 },
|
||||
},
|
||||
specialSponsors: [],
|
||||
sponsors: [],
|
||||
pastSponsors: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,19 +25,33 @@ export interface TechEdge {
|
||||
}
|
||||
|
||||
export interface Sponsor {
|
||||
sponsor: {
|
||||
login: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
websiteUrl?: string;
|
||||
linkUrl: string;
|
||||
customLogoUrl: string;
|
||||
type: string;
|
||||
};
|
||||
isOneTime: boolean;
|
||||
monthlyDollars: number;
|
||||
privacyLevel: string;
|
||||
name: string;
|
||||
githubId: string;
|
||||
avatarUrl: string;
|
||||
websiteUrl?: string;
|
||||
githubUrl: string;
|
||||
tierName: string;
|
||||
createdAt: string;
|
||||
provider: string;
|
||||
totalProcessedAmount?: number;
|
||||
sinceWhen: string;
|
||||
transactionCount: number;
|
||||
formattedAmount?: string;
|
||||
}
|
||||
|
||||
export interface SponsorsData {
|
||||
generated_at: string;
|
||||
summary: {
|
||||
total_sponsors: number;
|
||||
total_lifetime_amount: number;
|
||||
total_current_monthly: number;
|
||||
special_sponsors: number;
|
||||
current_sponsors: number;
|
||||
past_sponsors: number;
|
||||
top_sponsor: {
|
||||
name: string;
|
||||
amount: number;
|
||||
};
|
||||
};
|
||||
specialSponsors: Sponsor[];
|
||||
sponsors: Sponsor[];
|
||||
pastSponsors: Sponsor[];
|
||||
}
|
||||
|
||||
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -12,7 +12,6 @@ import type * as healthCheck from "../healthCheck.js";
|
||||
import type * as hooks from "../hooks.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as showcase from "../showcase.js";
|
||||
import type * as sponsors from "../sponsors.js";
|
||||
import type * as stats from "../stats.js";
|
||||
import type * as testimonials from "../testimonials.js";
|
||||
|
||||
@@ -35,7 +34,6 @@ declare const fullApi: ApiFromModules<{
|
||||
hooks: typeof hooks;
|
||||
http: typeof http;
|
||||
showcase: typeof showcase;
|
||||
sponsors: typeof sponsors;
|
||||
stats: typeof stats;
|
||||
testimonials: typeof testimonials;
|
||||
}>;
|
||||
|
||||
@@ -2,24 +2,6 @@ import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
sponsors: defineTable({
|
||||
sponsor: v.object({
|
||||
login: v.string(),
|
||||
name: v.string(),
|
||||
avatarUrl: v.string(),
|
||||
websiteUrl: v.optional(v.string()),
|
||||
linkUrl: v.string(),
|
||||
customLogoUrl: v.optional(v.string()),
|
||||
type: v.string(),
|
||||
}),
|
||||
isOneTime: v.boolean(),
|
||||
monthlyDollars: v.number(),
|
||||
privacyLevel: v.string(),
|
||||
tierName: v.string(),
|
||||
createdAt: v.string(),
|
||||
provider: v.string(),
|
||||
}),
|
||||
|
||||
videos: defineTable({
|
||||
embedId: v.string(),
|
||||
title: v.string(),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
import { query } from "./_generated/server";
|
||||
|
||||
export const getSponsors = query({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("sponsors"),
|
||||
_creationTime: v.number(),
|
||||
sponsor: v.object({
|
||||
login: v.string(),
|
||||
name: v.string(),
|
||||
avatarUrl: v.string(),
|
||||
websiteUrl: v.optional(v.string()),
|
||||
linkUrl: v.string(),
|
||||
customLogoUrl: v.optional(v.string()),
|
||||
type: v.string(),
|
||||
}),
|
||||
isOneTime: v.boolean(),
|
||||
monthlyDollars: v.number(),
|
||||
privacyLevel: v.string(),
|
||||
tierName: v.string(),
|
||||
createdAt: v.string(),
|
||||
provider: v.string(),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("sponsors").collect();
|
||||
},
|
||||
});
|
||||
0
test-conservative-calculation.js
Normal file
0
test-conservative-calculation.js
Normal file
0
test-lifetime-fix.js
Normal file
0
test-lifetime-fix.js
Normal file
0
test-sponsor-calculations.js
Normal file
0
test-sponsor-calculations.js
Normal file
Reference in New Issue
Block a user