diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index ed00d3e..ffc431d 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -1,7 +1,7 @@ --- name: implementer description: Implements a feature for Hacker House Protocol end-to-end following a plan from the Planner agent. Reads all relevant skills and existing code before writing anything, then executes the plan layer by layer. Runs pnpm build at the end and fixes all errors. -tools: Read, Write, Edit, Glob, Grep, Bash(pnpm build), Bash(pnpm lint), mcp__claude_ai_Supabase__apply_migration, mcp__claude_ai_Supabase__execute_sql, mcp__claude_ai_Supabase__list_tables, mcp__claude_ai_Supabase__list_migrations +tools: Read, Write, Edit, Glob, Grep, Bash(pnpm build), Bash(pnpm lint), mcp__supabase__apply_migration, mcp__supabase__execute_sql, mcp__supabase__list_tables, mcp__supabase__list_migrations --- You are a senior full-stack engineer for **Hacker House Protocol** — a Next.js 16 / Supabase / Privy app for Web3 builders. diff --git a/.claude/agents/ux-ui-designer.md b/.claude/agents/ux-ui-designer.md index a25404e..93fa1b8 100644 --- a/.claude/agents/ux-ui-designer.md +++ b/.claude/agents/ux-ui-designer.md @@ -14,10 +14,11 @@ You build production-grade UI that is visually distinctive, cohesive, and deligh Always read these before touching any file: -1. `docs/design-system.md` — **source of truth** for colors, typography, spacing, and components -2. `docs/navigation.md` — routes and page structure -3. The specific feature doc in `docs/features/.md` if it exists (for UI spec) -4. The existing component/page you're modifying or components similar to what you're building +1. `docs/design/design-system.md` — **source of truth** for colors, typography, spacing, and components +2. `.claude/skills/ui-components/SKILL.md` — required components and patterns. **Never rebuild what already exists in `components/ui/`** (Tabs, Select, Dialog, etc.) +3. `docs/design/navigation.md` — routes and page structure +4. The specific feature doc in `docs/features/.md` if it exists (for UI spec) +5. The existing component/page you're modifying or components similar to what you're building --- @@ -26,8 +27,8 @@ Always read these before touching any file: ### Visual language - **Always dark.** Never add light mode. The `html` element has a fixed `.dark` class. - **Surface hierarchy via luminosity** — use the token ladder, not box-shadows: - - `--background` (L=9%) → `--card` (L=13%) → `--muted` (L=17%) → `--secondary` (L=22%) → `--border` (L=28%) -- **Neon on dark** — `--primary` (purple, L=62%) pops against dark surfaces. Use it for CTAs, active states, focus rings. + - `--background` (L≈18%) → `--muted` (L≈22%) → `--card` (L≈23.5%) → `--secondary` (L≈28%) → `--border` (L≈32%) +- **Brand purple on dark** — `--primary` (`#6B00C9`, L≈45%) for CTAs, active states, focus rings. Text on primary is white (`--primary-foreground`). - **Archetype colors** for identity and role context: `--visionary` (magenta), `--strategist` (lavender), `--builder-archetype` (green). ### Typography @@ -60,7 +61,10 @@ Always read these before touching any file: - **No hardcoded colors** — only `var(--token)` or `color-mix(in oklch, var(--token) X%, transparent)` - **No emoji on archetypes** — `ARCHETYPES` has no `emoji` field. Render `name` + `colorVar` only. - **No ``** — use `DatePicker` from `@/components/ui/date-picker` -- **Check `components/ui/` before building custom** — the component likely already exists (Button, Input, Textarea, DatePicker, Calendar, Popover, Dialog, etc.) +- **Check `components/ui/` before building custom** — the component likely already exists (Button, Input, Textarea, DatePicker, Calendar, Popover, Dialog, Tabs, etc.) +- **Never hand-roll tabs or segmented toggles** — use `Tabs`/`TabsList`/`TabsTrigger` from `@/components/ui/tabs` and restyle via `className` if needed +- **`cursor-pointer` on custom clickables** — Tailwind v4 preflight sets `cursor: default` on buttons; any clickable that isn't the `Button` component needs explicit `cursor-pointer` +- **No opacity modifiers on readable gray text** — `text-muted-foreground` is the lowest-contrast tier allowed; `/50`-style opacity is only for decorative glyphs (min `/40`) - **File names**: kebab-case English always. No Spanish folder or file names. - **No `next/image`** — use plain `` tags (ESLint rule disabled for this project) - **Colocate route components**: `app//_components/` for non-shared components diff --git a/.claude/agents/verifier.md b/.claude/agents/verifier.md index d00fcdb..9261b01 100644 --- a/.claude/agents/verifier.md +++ b/.claude/agents/verifier.md @@ -46,6 +46,11 @@ For every changed file, check the following. Report only actual violations. - [ ] Pages use `` — no inline `max-w-* mx-auto px-* py-*` on `
` - [ ] No `
` blocks in pages +### UI components +- [ ] Tabs / segmented toggles use `Tabs`/`TabsList`/`TabsTrigger` from `@/components/ui/tabs` — never hand-rolled ` {step === STEPS[STEPS.length - 1] ? ( - ) : ( - )} diff --git a/.claude/skills/ui-components/SKILL.md b/.claude/skills/ui-components/SKILL.md index ee4ccc3..486c5c9 100644 --- a/.claude/skills/ui-components/SKILL.md +++ b/.claude/skills/ui-components/SKILL.md @@ -18,17 +18,36 @@ Always dark. Never add a light mode class or `light:` variant. The `.dark` class | Text input | `Input` | `@/components/ui/input` | Always inside `Field` + `FieldLabel` wrapper. | | Textarea | `Textarea` | `@/components/ui/textarea` | Same as Input. | | Select dropdown | `Select`, `SelectTrigger`, `SelectContent`, `SelectItem` | `@/components/ui/select` | Never native ``. Accepts `value: string` (ISO `YYYY-MM-DD`), `onChange`, `placeholder`, `fromDate`. Works with react-hook-form `Controller`. | | Page content wrapper | `PageContainer` | `@/app/(protected)/dashboard/_components/page-container` | Use in every dashboard page for consistent `max-w-6xl` width. | | Skeleton | `Skeleton` | `@/components/ui/skeleton` | Always use for loading states — never manual `animate-pulse` divs. | +**Cursor rule:** Tailwind v4 preflight sets `cursor: default` on ` + + diff --git a/app/(protected)/_components/notification-badge.tsx b/app/(protected)/_components/notification-badge.tsx index 87603ee..8f2152e 100644 --- a/app/(protected)/_components/notification-badge.tsx +++ b/app/(protected)/_components/notification-badge.tsx @@ -26,8 +26,8 @@ export function NotificationBadge({ className, )} style={{ - background: "var(--destructive)", - color: "var(--destructive-foreground)", + background: "var(--builder-archetype)", + color: "var(--background)", }} > {label} diff --git a/app/(protected)/_components/wallet-badge.tsx b/app/(protected)/_components/wallet-badge.tsx new file mode 100644 index 0000000..5a54fae --- /dev/null +++ b/app/(protected)/_components/wallet-badge.tsx @@ -0,0 +1,94 @@ +"use client" + +import { useState } from "react" +import { useWallets, getEmbeddedConnectedWallet } from "@privy-io/react-auth" +import { Wallet, Copy, Check, ExternalLink, Shield } from "lucide-react" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" + +function truncate(addr: string) { + return `${addr.slice(0, 6)}...${addr.slice(-4)}` +} + +export function WalletBadge() { + const { wallets } = useWallets() + const [copied, setCopied] = useState(false) + + const embedded = getEmbeddedConnectedWallet(wallets) + const privyWallet = wallets.find(w => w.walletClientType === "privy") + const wallet = embedded ?? privyWallet ?? wallets[0] ?? null + const address = wallet?.address ?? null + const walletType = wallet?.walletClientType ?? null + + if (!address) return null + + const walletLabel = walletType === "privy" ? "Embedded" : walletType === "metamask" ? "MetaMask" : walletType === "phantom" ? "Phantom" : "Wallet" + + function handleCopy() { + navigator.clipboard.writeText(address!) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + return ( +
+ + + + + +
+ {/* Privacy notice */} +
+ +

+ This is your smart wallet, created by the platform to protect your identity on-chain. Your personal wallet is never exposed. +

+
+ +
+ Network +
+
+ Arbitrum Sepolia +
+
+ +
+ Wallet + {walletLabel} +
+ +
+ Address + +
+ + + View on Arbiscan + + +
+ + +
+ ) +} diff --git a/app/(protected)/dashboard/_components/active-cities-section.tsx b/app/(protected)/dashboard/_components/active-cities-section.tsx new file mode 100644 index 0000000..de44574 --- /dev/null +++ b/app/(protected)/dashboard/_components/active-cities-section.tsx @@ -0,0 +1,175 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { Building2, Landmark, Code2, Globe } from "lucide-react" + +type Region = "South America" | "North America" | "Europe" | "Asia Pacific" + +interface CityEntry { + name: string + count: number + icon: React.ReactNode + color: string +} + +const regions: Region[] = ["South America", "North America", "Europe", "Asia Pacific"] + +const citiesByRegion: Record = { + "South America": [ + { + name: "Buenos Aires", + count: 48, + icon: , + color: "#6B00C9", + }, + { + name: "Medellín", + count: 23, + icon: , + color: "#990070", + }, + { + name: "São Paulo", + count: 31, + icon: , + color: "#8B78E6", + }, + { + name: "Bogotá", + count: 15, + icon: , + color: "#6EE76E", + }, + ], + "North America": [ + { + name: "New York", + count: 87, + icon: , + color: "#6B00C9", + }, + { + name: "San Francisco", + count: 112, + icon: , + color: "#8B78E6", + }, + { + name: "Denver", + count: 42, + icon: , + color: "#990070", + }, + { + name: "Austin", + count: 56, + icon: , + color: "#6EE76E", + }, + ], + Europe: [ + { + name: "Lisbon", + count: 63, + icon: , + color: "#8B78E6", + }, + { + name: "Berlin", + count: 74, + icon: , + color: "#6B00C9", + }, + { + name: "Cannes", + count: 29, + icon: , + color: "#990070", + }, + { + name: "Amsterdam", + count: 38, + icon: , + color: "#6EE76E", + }, + ], + "Asia Pacific": [ + { + name: "Singapore", + count: 95, + icon: , + color: "#6B00C9", + }, + { + name: "Bangkok", + count: 52, + icon: , + color: "#8B78E6", + }, + { + name: "Tokyo", + count: 41, + icon: , + color: "#990070", + }, + { + name: "Seoul", + count: 33, + icon: , + color: "#6EE76E", + }, + ], +} + +export function ActiveCitiesSection() { + const [activeRegion, setActiveRegion] = useState("South America") + + const cities = citiesByRegion[activeRegion] + + return ( +
+ {/* Title */} +

Explore Local Events

+ + {/* Region tabs */} +
+ {regions.map((region) => ( + + ))} +
+ + {/* City grid */} +
+ {cities.map((city) => ( + +
+ {city.icon} +
+
+

{city.name}

+

{city.count} Events

+
+ + ))} +
+
+ ) +} diff --git a/app/(protected)/dashboard/_components/builder-card.tsx b/app/(protected)/dashboard/_components/builder-card.tsx index c22bf65..0ba2124 100644 --- a/app/(protected)/dashboard/_components/builder-card.tsx +++ b/app/(protected)/dashboard/_components/builder-card.tsx @@ -2,24 +2,10 @@ import Link from "next/link" import { ARCHETYPES } from "@/lib/onboarding" -import { Badge, type badgeVariants } from "@/components/ui/badge" +import { resolveSkill } from "@/lib/skill-icons" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { ConnectButton } from "./connect-button" import type { UserProfile, SuggestedBuilder } from "@/lib/types" -import type { VariantProps } from "class-variance-authority" - -type BadgeVariant = VariantProps["variant"] - -const ARCHETYPE_BADGE_VARIANT: Record = { - visionary: "visionary-outline", - strategist: "strategist-outline", - builder: "builder-outline", -} - -function isSuggestedBuilder( - builder: UserProfile | SuggestedBuilder, -): builder is SuggestedBuilder { - return "match_score" in builder -} interface BuilderCardProps { builder: UserProfile | SuggestedBuilder @@ -27,17 +13,46 @@ interface BuilderCardProps { showMatchInfo?: boolean } +function SkillIconRow({ skills, max = 4 }: { skills: string[]; max?: number }) { + const visible = skills.slice(0, max) + const rest = skills.length - max + if (visible.length === 0) return null + return ( +
+ {visible.map((skill) => { + const def = resolveSkill(skill) + return ( + + +
+ {def.emoji ? ( + {def.icon} + ) : ( + {def.label} + )} +
+
+ +

{def.label}

+
+
+ ) + })} + {rest > 0 && ( + +{rest} + )} +
+ ) +} + export function BuilderCard({ builder, currentUserId, - showMatchInfo = false, }: BuilderCardProps) { const archetype = ARCHETYPES.find((a) => a.id === builder.archetype) - const skills = builder.skills ?? [] - const visibleSkills = skills.slice(0, 3) - const extraSkills = skills.length - 3 - const talentTags = builder.talent_tags ?? [] - const visibleTags = talentTags.slice(0, 2) + const allSkills = [ + ...new Set([...(builder.talent_tags ?? []), ...(builder.skills ?? [])]), + ] const displayName = builder.handle ? `@${builder.handle}` @@ -51,11 +66,11 @@ export function BuilderCard({ const isOwnCard = currentUserId === builder.id - const cardContent = ( -
+ const inner = ( +
{/* Avatar */}
{/* Handle */} -

+

{displayName} -

+

- {/* Archetype badge */} - {archetype && ( - - {archetype.label} - - )} - - {/* Bio */} - {builder.bio && ( -

- {builder.bio} -

- )} + {/* Skill icons */} + {allSkills.length > 0 && } - {/* Skills */} - {visibleSkills.length > 0 && ( -
- {visibleSkills.map((skill) => ( - - {skill} - - ))} - {extraSkills > 0 && ( - - +{extraSkills} - - )} -
- )} - - {/* Talent Tags */} - {visibleTags.length > 0 && ( -
- {visibleTags.map((tag) => ( - - {tag} - - ))} -
- )} - - {/* Match info for suggested builders */} - {showMatchInfo && isSuggestedBuilder(builder) && ( -
- - {Math.round(builder.match_score)}% match - - {builder.match_reasons.length > 0 && ( -

- {builder.match_reasons[0]} -

- )} + {/* Connect button */} + {!isOwnCard && ( +
e.preventDefault()}> +
)}
) return ( -
+
{href ? ( - - {cardContent} - + {inner} ) : ( -
{cardContent}
- )} - - {/* Connect button outside the link to prevent navigation */} - {!isOwnCard && ( -
- -
+ inner )}
) diff --git a/app/(protected)/dashboard/_components/communities-feed.tsx b/app/(protected)/dashboard/_components/communities-feed.tsx new file mode 100644 index 0000000..41510bc --- /dev/null +++ b/app/(protected)/dashboard/_components/communities-feed.tsx @@ -0,0 +1,89 @@ +"use client" + +import Link from "next/link" +import { ArrowRight } from "lucide-react" +import { useFilteredCommunities } from "@/services/api/communities" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { CommunityCard } from "./community-card" + +const PREVIEW_LIMIT = 4 + +export function CommunitiesFeed() { + const { data, isLoading } = useFilteredCommunities({}) + const communities = data?.pages.flatMap((p) => p.communities) ?? [] + const total = data?.pages[0]?.total ?? 0 + const preview = communities.slice(0, PREVIEW_LIMIT) + + return ( +
+ {/* Header */} +
+
+

Communities

+ {!isLoading && total > 0 && ( + + {total} + + )} +
+
+ {communities.length > 0 && ( + + See all + + )} + + + +
+
+ + {/* Content */} + {isLoading ? ( +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ +
+ + + +
+
+ ))} +
+
+ ) : communities.length === 0 ? ( +
+
+

No communities yet.

+

+ Be the first to create a community and bring builders together. +

+
+ + + +
+ ) : ( +
+
+ {preview.map((c) => ( +
+ +
+ ))} +
+
+ )} +
+ ) +} diff --git a/app/(protected)/dashboard/_components/community-card.tsx b/app/(protected)/dashboard/_components/community-card.tsx new file mode 100644 index 0000000..f7fb7c5 --- /dev/null +++ b/app/(protected)/dashboard/_components/community-card.tsx @@ -0,0 +1,69 @@ +"use client" + +import Link from "next/link" +import { Check, Users, BadgeCheck, Star } from "lucide-react" +import { useJoinCommunity } from "@/services/api/communities" +import { Button } from "@/components/ui/button" +import type { Community } from "@/lib/types" + +export function CommunityCard({ community }: { community: Community }) { + const joinMutation = useJoinCommunity(community.id) + + return ( +
+ {/* Banner */} + +
+ {community.image_url ? ( + {community.name} + ) : ( +
+ )} +
+ + {community.category} + +
+ + + {/* Content */} +
+ +

+ {community.name} + {community.is_verified && } + {community.is_featured && } +

+ + +

+ {community.description} +

+ + {/* Members + button always anchored to bottom */} +
+
+ + {community.member_count} members +
+ {community.is_member ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/app/(protected)/dashboard/_components/connect-button.tsx b/app/(protected)/dashboard/_components/connect-button.tsx index c32699f..db7a7d2 100644 --- a/app/(protected)/dashboard/_components/connect-button.tsx +++ b/app/(protected)/dashboard/_components/connect-button.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { Button } from "@/components/ui/button" import { Spinner } from "@/components/ui/spinner" +import { UserPlus, Check, Clock, UserMinus } from "lucide-react" import { useFriendshipStatus, useSendFriendRequest, @@ -25,7 +26,7 @@ export function ConnectButton({ targetUserId }: ConnectButtonProps) { const removeFriendship = useRemoveFriendship(friendshipId) if (isLoading) { - return + return } const status = friendshipData?.status ?? null @@ -36,17 +37,20 @@ export function ConnectButton({ targetUserId }: ConnectButtonProps) { return ( ) @@ -55,13 +59,8 @@ export function ConnectButton({ targetUserId }: ConnectButtonProps) { // Pending - I sent the request if (status === "pending" && direction === "sent") { return ( - ) @@ -70,27 +69,23 @@ export function ConnectButton({ targetUserId }: ConnectButtonProps) { // Pending - I received the request if (status === "pending" && direction === "received") { return ( -
+
@@ -102,27 +97,21 @@ export function ConnectButton({ targetUserId }: ConnectButtonProps) { if (status === "accepted") { if (showRemoveConfirm) { return ( -
+
@@ -133,11 +122,11 @@ export function ConnectButton({ targetUserId }: ConnectButtonProps) { return ( ) diff --git a/app/(protected)/dashboard/_components/context-banner.tsx b/app/(protected)/dashboard/_components/context-banner.tsx new file mode 100644 index 0000000..a152ec8 --- /dev/null +++ b/app/(protected)/dashboard/_components/context-banner.tsx @@ -0,0 +1,20 @@ +import Link from "next/link" +import { ArrowRight } from "lucide-react" + +export function ContextBanner() { + return ( +
+

+ 3 builders in your network are going to{" "} + ETH Cannes in 18 days —{" "} + 2 Hacker Houses open for that event. +

+ + See houses + +
+ ) +} diff --git a/app/(protected)/dashboard/_components/create-hack-space-modal.tsx b/app/(protected)/dashboard/_components/create-hack-space-modal.tsx index fcfee4b..ea8fcc3 100644 --- a/app/(protected)/dashboard/_components/create-hack-space-modal.tsx +++ b/app/(protected)/dashboard/_components/create-hack-space-modal.tsx @@ -228,8 +228,9 @@ export function CreateHackSpaceModal({ diff --git a/app/(protected)/dashboard/_components/cypher-identity-card.tsx b/app/(protected)/dashboard/_components/cypher-identity-card.tsx index 8788efa..56a574c 100644 --- a/app/(protected)/dashboard/_components/cypher-identity-card.tsx +++ b/app/(protected)/dashboard/_components/cypher-identity-card.tsx @@ -128,7 +128,7 @@ export function CypherIdentityCard({ profile }: CypherIdentityCardProps) { ) : ( Link wallet in profile diff --git a/app/(protected)/dashboard/_components/dashboard-navbar.tsx b/app/(protected)/dashboard/_components/dashboard-navbar.tsx index e1cd2b0..8f5fdb3 100644 --- a/app/(protected)/dashboard/_components/dashboard-navbar.tsx +++ b/app/(protected)/dashboard/_components/dashboard-navbar.tsx @@ -23,9 +23,9 @@ export function DashboardNavbar() { Hacker House Protocol HHP diff --git a/app/(protected)/dashboard/_components/hack-space-card.tsx b/app/(protected)/dashboard/_components/hack-space-card.tsx index 4223f72..9294fa2 100644 --- a/app/(protected)/dashboard/_components/hack-space-card.tsx +++ b/app/(protected)/dashboard/_components/hack-space-card.tsx @@ -1,29 +1,9 @@ "use client" -import { useState } from "react" import Link from "next/link" -import { useApplyToHackSpace } from "@/services/api/hack-spaces" import { ARCHETYPES } from "@/lib/onboarding" import type { HackSpace } from "@/lib/types" -import { Button } from "@/components/ui/button" -import { Badge, type badgeVariants } from "@/components/ui/badge" -import { Textarea } from "@/components/ui/textarea" -import type { VariantProps } from "class-variance-authority" -type BadgeVariant = VariantProps["variant"] - -const ARCHETYPE_BADGE_VARIANT: Record = { - visionary: "visionary-outline", - strategist: "strategist-outline", - builder: "builder-outline", -} - -const STATUS_CONFIG = { - open: { label: "Looking for members", colorVar: "--primary" }, - full: { label: "Team full", colorVar: "--builder-archetype" }, - in_progress: { label: "In progress", colorVar: "--strategist" }, - finished: { label: "Finished", colorVar: "--muted-foreground" }, -} as const interface HackSpaceCardProps { hackSpace: HackSpace @@ -34,33 +14,18 @@ export function HackSpaceCard({ hackSpace, currentUserId, }: HackSpaceCardProps) { - const apply = useApplyToHackSpace(hackSpace.id) - const [showApplyForm, setShowApplyForm] = useState(false) - const [message, setMessage] = useState("") - - const isOwner = currentUserId === hackSpace.creator.id - const creatorArchetype = ARCHETYPES.find( - (a) => a.id === hackSpace.creator.archetype, - ) - const statusCfg = STATUS_CONFIG[hackSpace.status] ?? STATUS_CONFIG.open - - const visibleSkills = hackSpace.skills_needed.slice(0, 3) - const extraSkills = hackSpace.skills_needed.length - 3 - - function handleApply() { - apply.mutate( - { message: message || undefined }, - { onSuccess: () => setShowApplyForm(false) }, - ) - } - const memberCount = hackSpace.member_count ?? 0 const participants = hackSpace.participants ?? [] + void currentUserId + return ( -
+
+ {/* Full-card tap target */} + + {/* Cover image */} -
+
{hackSpace.image_url ? (
-
- {/* Header */} -
- {/* Title + status */} -
-
-

- {hackSpace.title} -

+
+ {/* Title — single line, truncated */} +

+ {hackSpace.title} +

+ + {/* Description — fixed 2-line height */} +

+ {hackSpace.description} +

+ + {/* Looking for + Skills — grouped */} +
+ {/* Looking for archetypes — always reserves space */} +
+ {hackSpace.looking_for.length > 0 && ( + <> + Looking for: +
+ {hackSpace.looking_for.map((roleId) => { + const arch = ARCHETYPES.find((a) => a.id === roleId) + return ( + + {arch?.label ?? roleId} + + ) + })} +
+ + )} +
+ + {/* Skills — 2 visible max, full text, +N for rest */} +
+ {hackSpace.skills_needed.slice(0, 2).map((skill) => ( - {statusCfg.label} + {skill} -
-

- by{" "} - - @{hackSpace.creator.handle ?? "anon"} - - {creatorArchetype && ( - - {" "} - · {creatorArchetype.label} - - )} -

+ ))} + {hackSpace.skills_needed.length > 2 && ( + + +{hackSpace.skills_needed.length - 2} + + )}
- {/* Description */} -

- {hackSpace.description} -

+ {/* Spacer */} +
- {/* Pills: skills + archetypes — fixed area so footer stays anchored */} -
- {hackSpace.skills_needed.length > 0 && ( -
- {visibleSkills.map((skill) => ( - - {skill} - - ))} - {extraSkills > 0 && ( - - +{extraSkills} - - )} -
- )} - {hackSpace.looking_for.length > 0 && ( -
- {hackSpace.looking_for.map((archetypeId) => { - const a = ARCHETYPES.find((x) => x.id === archetypeId) - if (!a) return null + {/* Footer: separator → avatars → spots + View */} +
+ {participants.length > 0 && ( +
+ {participants.slice(0, 6).map((p, i) => { + const arch = ARCHETYPES.find((a) => a.id === p.archetype) return ( - - {a.label} - + {p.avatar_url ? ( + {p.handle + ) : ( + + {p.handle?.charAt(0)?.toUpperCase() ?? "?"} + + )} +
) })}
)} -
- - {/* Spacer */} -
- - {/* Footer */} -
- {/* Left: member dots + metadata */} -
-
- {participants.slice(0, 6).map((p, i) => ( -
- {p.avatar_url ? ( - {p.handle - ) : ( -
- )} -
- ))} - {memberCount > 6 && ( - +{memberCount - 6} - )} - - {memberCount}/{hackSpace.max_team_size} - -
-
- {hackSpace.language.length > 0 && ( - {hackSpace.language.slice(0, 2).join(" · ")} - )} - {(hackSpace.city || hackSpace.country || hackSpace.region) && ( - <> - · - - {hackSpace.city ?? hackSpace.country ?? hackSpace.region} - - - )} - {hackSpace.event_name && ( - <> - · - - {hackSpace.event_name} - {hackSpace.event_start_date && ( - <> - {" "} - {hackSpace.event_end_date - ? `${new Date(hackSpace.event_start_date).getDate()}–${new Date(hackSpace.event_end_date).toLocaleDateString("en-US", { day: "numeric", month: "short" })}` - : new Date(hackSpace.event_start_date).toLocaleDateString("en-US", { day: "numeric", month: "short" })} - - )} - - - )} -
-
- - {/* Right: CTA */} - {isOwner ? ( - - - - ) : hackSpace.status === "full" || hackSpace.status === "finished" ? ( - - - - ) : apply.isSuccess ? ( - - ✓ Applied +
+ + {memberCount}/{hackSpace.max_team_size} spots - ) : showApplyForm ? ( - - Applying... + + View → - ) : ( - - )} -
- - {/* Inline apply form */} - {showApplyForm && !apply.isSuccess && ( -
-