Skip to content

Commit 533de6f

Browse files
Redesignou marketplace como openalternative
X-Lovable-Edit-ID: edt-b61c5c0b-ec2b-4128-a3c1-721c0bb0030e Co-authored-by: criptogus <128640021+criptogus@users.noreply.github.com>
2 parents 35048fa + 157f9f4 commit 533de6f

3 files changed

Lines changed: 475 additions & 405 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useNavigate } from "@tanstack/react-router";
2+
import { BadgeCheck } from "lucide-react";
3+
4+
export function AuthorLink({ handle, verified }: { handle: string; verified?: boolean }) {
5+
const navigate = useNavigate();
6+
const slug = handle.replace(/^@/, "");
7+
return (
8+
<button
9+
type="button"
10+
onClick={(e) => {
11+
e.preventDefault();
12+
e.stopPropagation();
13+
navigate({ to: "/u/$handle", params: { handle: slug } });
14+
}}
15+
className="inline-flex items-center font-medium text-muted-foreground hover:text-primary hover:underline"
16+
>
17+
{handle}
18+
{verified && (
19+
<span className="ml-1 inline-flex text-primary">
20+
<BadgeCheck className="h-3.5 w-3.5" aria-hidden />
21+
<span className="sr-only">Verified author</span>
22+
</span>
23+
)}
24+
</button>
25+
);
26+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { Link } from "@tanstack/react-router";
2+
import { BookOpen, Bot, ShieldCheck, Sparkles, User } from "lucide-react";
3+
import type { LucideIcon } from "lucide-react";
4+
import type { MarketplaceItem } from "@/lib/marketplace/list.functions";
5+
import { trustTier, TRUST_TIER_LABELS, type TrustTier } from "@/lib/marketplace/trust-tiers";
6+
import { BuyButton, PriceBadge } from "@/components/marketplace/BuyButton";
7+
import { ShareOnXButton } from "@/components/share/ShareOnXButton";
8+
import { SoulDisclaimerBadge } from "@/components/souls/SoulDisclaimer";
9+
import { AuthorLink } from "@/components/marketplace/AuthorLink";
10+
import { Stars } from "@/components/reviews/Stars";
11+
12+
const TYPE_ICON: Record<MarketplaceItem["type"], LucideIcon> = {
13+
skill: Sparkles,
14+
playbook: BookOpen,
15+
soul: User,
16+
guardrail: ShieldCheck,
17+
};
18+
19+
const TYPE_TONE: Record<MarketplaceItem["type"], string> = {
20+
skill: "border-blue-500/25 bg-blue-500/10 text-blue-600 dark:text-blue-400",
21+
playbook: "border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-400",
22+
soul: "border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400",
23+
guardrail: "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
24+
};
25+
26+
const TIER_TONE: Record<TrustTier, string> = {
27+
verified: "text-emerald-600 dark:text-emerald-400",
28+
high: "text-sky-600 dark:text-sky-400",
29+
baseline: "text-amber-600 dark:text-amber-400",
30+
low: "text-muted-foreground",
31+
unscored: "text-muted-foreground",
32+
};
33+
34+
/** A stat row with a dotted leader line between label and value (directory style). */
35+
function StatRow({
36+
icon: Icon,
37+
label,
38+
children,
39+
title,
40+
}: {
41+
icon: LucideIcon;
42+
label: string;
43+
children: React.ReactNode;
44+
title?: string;
45+
}) {
46+
return (
47+
<div className="flex items-center gap-2 text-xs" title={title}>
48+
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
49+
<span className="text-muted-foreground">{label}</span>
50+
<span aria-hidden className="mt-1 flex-1 border-b border-dashed border-border" />
51+
<span className="shrink-0 font-mono text-[11px] text-foreground">{children}</span>
52+
</div>
53+
);
54+
}
55+
56+
export function PackageCard({ p }: { p: MarketplaceItem }) {
57+
const tier = trustTier(p.trust_score, p.trust_verified);
58+
const Icon = TYPE_ICON[p.type];
59+
const linkProps =
60+
p.type === "soul"
61+
? ({ to: "/souls/$slug", params: { slug: p.slug } } as const)
62+
: ({ to: "/marketplace/$packageId", params: { packageId: p.slug } } as const);
63+
64+
return (
65+
<div className="group relative flex flex-col rounded-xl border border-border bg-card p-5 transition-colors focus-within:border-primary/40 hover:border-primary/40 hover:shadow-elevated">
66+
{/* Title row: icon tile + name + type */}
67+
<div className="flex items-start gap-3">
68+
<span
69+
className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border ${TYPE_TONE[p.type]}`}
70+
>
71+
<Icon className="h-5 w-5" aria-hidden />
72+
</span>
73+
<div className="min-w-0 flex-1">
74+
<h3 className="line-clamp-2 text-base font-semibold leading-tight tracking-tight">
75+
{/* Stretched link covers the card; interactive siblings sit above via z-10 */}
76+
<Link {...linkProps} className="after:absolute after:inset-0 after:content-['']">
77+
{p.name}
78+
</Link>
79+
</h3>
80+
<div className="relative z-10 mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
81+
<span className="font-mono uppercase tracking-wider">{p.type}</span>
82+
<span aria-hidden>·</span>
83+
<AuthorLink handle={p.author_handle} verified={p.author_verified} />
84+
</div>
85+
</div>
86+
<div className="relative z-10 flex shrink-0 items-center gap-1.5">
87+
{p.type !== "soul" && <PriceBadge priceCredits={p.price_credits} />}
88+
<ShareOnXButton
89+
slug={p.slug}
90+
type={p.type}
91+
name={p.name}
92+
description={p.description}
93+
url={p.type === "soul" ? `/souls/${p.slug}` : `/marketplace/${p.slug}`}
94+
variant="icon"
95+
/>
96+
</div>
97+
</div>
98+
99+
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">{p.description}</p>
100+
101+
{p.type === "soul" && (
102+
<div className="relative z-10 mt-2">
103+
<SoulDisclaimerBadge name={p.name} />
104+
</div>
105+
)}
106+
107+
{/* Stat block with leader lines */}
108+
<div className="mt-4 space-y-1.5 border-t border-border pt-4">
109+
<StatRow
110+
icon={ShieldCheck}
111+
label="Trust"
112+
title={
113+
p.trust_score != null
114+
? `Trust score ${(p.trust_score * 100).toFixed(0)}/100`
115+
: "No trust score yet — not adversarially tested"
116+
}
117+
>
118+
<span className={TIER_TONE[tier]}>
119+
{p.trust_score != null
120+
? `${(p.trust_score * 100).toFixed(0)}/100 · ${TRUST_TIER_LABELS[tier]}`
121+
: TRUST_TIER_LABELS[tier]}
122+
</span>
123+
</StatRow>
124+
<StatRow icon={Bot} label="Installs">
125+
{p.install_count.toLocaleString("en-US")}
126+
</StatRow>
127+
<StatRow icon={Sparkles} label="Rating">
128+
{p.rating_count > 0 ? (
129+
<span className="inline-flex items-center gap-1.5">
130+
<Stars value={p.rating_avg} size={11} />
131+
{p.rating_avg.toFixed(1)} ({p.rating_count})
132+
</span>
133+
) : (
134+
<span className="text-muted-foreground"></span>
135+
)}
136+
</StatRow>
137+
<StatRow icon={BookOpen} label="Version">
138+
v{p.latest_version}
139+
</StatRow>
140+
</div>
141+
142+
<div className="mt-auto flex items-center gap-2 pt-4">
143+
<div
144+
aria-hidden
145+
className="pointer-events-none inline-flex h-9 flex-1 items-center justify-center rounded-md border border-border bg-surface text-sm font-medium transition-colors group-hover:border-primary/40 group-hover:text-primary"
146+
>
147+
{p.type === "soul" ? "View soul" : "View package"}
148+
</div>
149+
{p.type !== "soul" && p.price_credits > 0 && (
150+
<div className="relative z-10">
151+
<BuyButton packageId={p.id} priceCredits={p.price_credits} />
152+
</div>
153+
)}
154+
</div>
155+
156+
{p.vertical && (
157+
<span className="pointer-events-none absolute right-4 top-16 hidden text-[10px] uppercase tracking-wider text-muted-foreground/70 xl:block">
158+
{p.vertical}
159+
</span>
160+
)}
161+
</div>
162+
);
163+
}

0 commit comments

Comments
 (0)