diff --git a/content/brand-guide/SOLFOUNDRY_BRAND_GUIDE.md b/content/brand-guide/SOLFOUNDRY_BRAND_GUIDE.md new file mode 100644 index 000000000..82ef81a0b --- /dev/null +++ b/content/brand-guide/SOLFOUNDRY_BRAND_GUIDE.md @@ -0,0 +1,428 @@ +# SolFoundry Brand Guide + +> **Version:** 1.0 | **Status:** Active | **Owner:** SolFoundry +> +> This guide defines the SolFoundry brand identity for internal teams, contributors, and partners. +> For design assets (logos, templates), see the companion [`/content/social-media-templates/`](https://github.com/SolFoundry/solfoundry/tree/main/content/social-media-templates) directory. + +--- + +## Table of Contents + +1. [Brand Essence](#1-brand-essence) +2. [Logo](#2-logo) +3. [Color Palette](#3-color-palette) +4. [Typography](#4-typography) +5. [Iconography](#5-iconography) +6. [Tone of Voice](#6-tone-of-voice) +7. [Design Principles](#7-design-principles) +8. [Imagery & Photography](#8-imagery--photography) +9. [Tier System](#9-tier-system) +10. [Do's & Don'ts](#10-dos--donts) + +--- + +## 1. Brand Essence + +### Brand Promise +SolFoundry is where Solana developers earn, learn, and build the open web. We are the forge where ideas become working code, and code becomes value. + +### Mission +Democratize software development by connecting skilled builders with funded opportunities — no gatekeepers, no friction. + +### Values +| Value | Meaning | +|-------|---------| +| **Open** | Public by default; all processes visible on-chain | +| **Fast** | Like Solana — instant, cheap, scalable | +| **Trust** | Escrow-protected payments, transparent reviews | +| **Craft** | Quality code, quality interactions | + +### Brand Personality +- **Confident, not arrogant** — we know what we're building +- **Direct, not blunt** — clear communication without noise +- **Technical, not cold** — we speak builder, but we don't exclude newcomers +- **Ambitious, not hype** — we ship real things, not promises + +--- + +## 2. Logo + +### Wordmark +The **SOLFOUNDRY** wordmark is the primary logo. Use it in full at every reasonable opportunity. + +**Primary variant:** White wordmark on dark backgrounds (`forge-900` or darker) +**Secondary variant:** Emerald (`#00E676`) wordmark on light/white backgrounds + +### Symbol Mark (Forge Icon ⚒) +The forge anvil/hammer symbol is used in contexts where the full wordmark won't fit: +- Favicons, app icons +- Social media avatars (when used alone) +- Inline iconography (max 32×32px) +- Footer credits + +### Clearspace +Minimum clearspace around the logo = **height of the "S"** on all sides. No other elements (text, imagery, borders) may intrude into this zone. + +### Logo Don'ts +- ❌ Do not stretch, skew, or rotate the logo +- ❌ Do not change the wordmark font +- ❌ Do not apply a drop shadow to the logo +- ❌ Do not place the logo on a busy photographic background without a clear zone +- ❌ Do not use the logo in a sentence or as body text +- ❌ Do not recreate the logo from scratch — always use the official asset + +### Usage Examples +| Context | Recommended Variant | +|---------|-------------------| +| Dark website header | White wordmark | +| White/dark card backgrounds | Emerald wordmark or white | +| Social media banner | White wordmark + tagline | +| Print / PDF report | White or black wordmark | +| Monochrome use (fax, B&W print) | Black wordmark | + +--- + +## 3. Color Palette + +### Primary Brand Colors + +#### Emerald — Signature Color +The cornerstone of SolFoundry's identity. Use for primary CTAs, active states, positive indicators, and key highlights. + +``` +#00E676 — Emerald (DEFAULT / Primary) +#69F0AE — Emerald Light (hover states, gradients) +``` + +#### Purple — Secondary / Depth +Used for secondary accents, category labels, and gradient blends. + +``` +#7C3AED — Purple (DEFAULT) +#A78BFA — Purple Light +``` + +#### Magenta — Highlight / Energy +Used sparingly for callouts, special events, or energy moments. + +``` +#E040FB — Magenta (DEFAULT) +#EA80FC — Magenta Light +``` + +### Neutrals — Forge Scale +All UI backgrounds, cards, borders, and surfaces use the Forge scale. + +| Token | Hex | Usage | +|-------|-----|-------| +| `forge-950` | `#050505` | Deepest background (app root) | +| `forge-900` | `#0A0A0F` | Primary background | +| `forge-850` | `#0F0F18` | Card/surface background | +| `forge-800` | `#16161F` | Elevated surfaces | +| `forge-700` | `#1E1E2A` | Borders, dividers | +| `forge-600` | `#2A2A3A` | Muted backgrounds | + +### Text Colors + +| Token | Hex | Usage | +|-------|-----|-------| +| `text-primary` | `#F0F0F5` | Headlines, primary content | +| `text-secondary` | `#A0A0B8` | Body text, descriptions | +| `text-muted` | `#5C5C78` | Captions, placeholders, timestamps | + +### Border / Dividers + +| Token | Hex | Usage | +|-------|-----|-------| +| `border` | `#1E1E2E` | Default borders | +| `border-hover` | `#2E2E42` | Hover state borders | +| `border-active` | `#3E3E56` | Active/focus borders | + +### Status Colors + +| Token | Hex | Usage | +|-------|-----|-------| +| `status-success` | `#00E676` | Completed, paid, active | +| `status-warning` | `#FFB300` | Pending, review, attention | +| `status-error` | `#FF5252` | Failed, rejected, error | +| `status-info` | `#40C4FF` | Informational, neutral notices | + +### Tier Colors + +| Tier | Hex | Usage | +|------|-----|-------| +| T1 | `#00E676` | Open race, entry-level bounties | +| T2 | `#40C4FF` | Intermediate complexity | +| T3 | `#7C3AED` | Advanced, high-value bounties | + +### Gradient Presets + +```css +/* Navbar / Footer gradient */ +background: linear-gradient(90deg, #00E676, #7C3AED, #E040FB); + +/* Hero background */ +background: radial-gradient(ellipse at 50% 0%, + rgba(124,58,237,0.15) 0%, + rgba(224,64,251,0.08) 40%, + transparent 70%); + +/* Grid overlay */ +background-image: + linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px); +background-size: 40px 40px; +``` + +### Color Accessibility +- All text/background combinations meet **WCAG AA** contrast ratio (4.5:1 for body text) +- Emerald `#00E676` on `forge-900` `#0A0A0F` → ratio ≈ 10.5:1 ✅ +- `text-primary` `#F0F0F5` on `forge-850` `#0F0F18` → ratio ≈ 14:1 ✅ +- Avoid using `text-muted` for body text longer than 2 lines + +--- + +## 4. Typography + +### Type Scale + +| Role | Font | Size | Weight | Line Height | +|------|------|------|--------|-------------| +| Display / Hero | Orbitron | 48–72px | 700–900 | 1.1 | +| H1 / Page Title | Orbitron | 32–40px | 700 | 1.2 | +| H2 / Section | Inter | 24–28px | 600 | 1.3 | +| H3 / Subsection | Inter | 18–20px | 600 | 1.4 | +| Body | Inter | 15–16px | 400 | 1.6 | +| Small / Caption | Inter | 13–14px | 400 | 1.5 | +| Mono / Code | JetBrains Mono | 13–15px | 400–500 | 1.6 | +| Label / Badge | Inter | 11–13px | 600–700 | 1.0 | + +### Font Families + +```css +font-display: 'Orbitron', sans-serif; /* Logo, display headings only */ +font-sans: 'Inter', system-ui, sans-serif; /* All body copy */ +font-mono: 'JetBrains Mono', 'Fira Code', monospace; /* Code, addresses, hashes */ +``` + +### Google Fonts Import + +```html + +``` + +### Type Rules + +1. **Orbitron** is reserved for display use only — never use it for body text +2. **Inter** is the default body font — readable at 15–16px minimum +3. **JetBrains Mono** for wallet addresses, commit hashes, transaction IDs +4. Avoid custom font stacks on user-generated content (stick to `system-ui` fallback) +5. Letter-spacing: uppercase labels may use `letter-spacing: 2px` for a technical feel + +--- + +## 5. Iconography + +### Primary Icon Library +**Lucide React** — consistent 24px stroke icons throughout the product. + +Key icons used across the product: +- `⚒` / Anvil emoji for the forge brand (informal contexts only) +- `Search`, `Filter`, `SortAsc` for bounty listing +- `Clock`, `Timer` for deadlines +- `GitPullRequest`, `GitMerge` for PR lifecycle +- `Coins`, `DollarSign` for payouts +- `CheckCircle`, `XCircle` for completion states +- `Shield`, `Lock` for security / escrow + +### Icon Usage Rules +- Size: **20px** default, **16px** compact, **24px** feature +- Stroke weight: **1.5px** standard, **2px** emphasis +- Color: inherits `currentColor` — use `text-emerald` for active, `text-muted` for inactive +- Never mix icon styles (e.g., don't combine outlined and filled in the same component) + +--- + +## 6. Tone of Voice + +### How We Speak + +| Situation | Do | Don't | +|-----------|---|-------| +| Onboarding a new builder | "Start earning now" | "Get started today!" | +| Announcing a payout | "Bounty paid — $500 released" | "Exciting news! We just paid out..." | +| Describing a bounty | "Build X that does Y" | "Revolutionary solution to Y using cutting-edge X" | +| Error state | "Payment failed. Try again." | "Oops! Something went wrong :((" | +| Announcing a new feature | "New: Bounty analytics dashboard" | "Introducing our amazing new dashboard feature!" | + +### Voice Attributes + +**Clear** — Say what you mean. Technical terms are fine (we're talking to builders), but explain when necessary. + +**Direct** — Short sentences. Active voice. No corporate softening ("please note that..."). + +**Confident** — "The best bounties on Solana." Not "We think we might have some of the best bounties." Don't use "hopefully" or "try to." + +**Human** — We use some humor, especially on social. We're not a bank. But we don't force it. + +### Boilerplate Copy + +> **One-liner:** SolFoundry is where Solana builders earn, learn, and ship — funded bounties, no gatekeepers. +> +> **Short description:** A bounty platform on Solana connecting skilled developers with funded projects. Payments are escrowed and instant. +> +> **Full description:** SolFoundry is a decentralized bounty marketplace built on Solana. Developers discover, claim, and complete bounties — from bug fixes to full features — with payments protected by smart contract escrow. No middlemen, no complicated payment negotiations. + +--- + +## 7. Design Principles + +### 1. Forge Aesthetic +Dark-first. Every surface lives in the forge scale. Use emerald as the signature accent — sparingly, with intention. + +### 2. Depth Through Subtlety +Don't rely on flat colors alone. Use glow effects (`emerald glow: rgba(0,230,118,0.15)`), gradient overlays, and subtle grid patterns to create depth without noise. + +### 3. Signal Over Decoration +Every visual element should communicate something. If it doesn't, remove it. Avoid decorative borders, ornamental icons, or "flavor" graphics that don't add information. + +### 4. Motion With Purpose +Animations should communicate state changes (loading, success, transition) — not just entertain. Use `ease-out` for entrances, `ease-in` for exits. Keep duration under 300ms for UI transitions. + +### 5. Responsive First +The platform must work on mobile wallets, tablets, and desktop. Mobile is not an afterthought. If it doesn't work on mobile, it doesn't work. + +### 6. Trust Through Clarity +Escrow status, payment status, review status — always visible, always clear. Use status colors consistently (green = good, amber = pending, red = problem). + +--- + +## 8. Imagery & Photography + +### Style +- **Dark, moody, technical** — server rooms, code editors, terminal windows, circuit boards +- **Real people, technical settings** — avoid generic stock photos of "happy businesspeople shaking hands" +- **Solana-adjacent** — purple/or teal color grading, blockchain visuals, network diagrams +- **Avoid**: bright white backgrounds, clip art, dated tech imagery + +### User-Generated Content +- Show real code, real PRs, real avatars from the platform +- Use placeholder avatars that match the brand aesthetic (gradient circles with initials) + +### Badges & Achievement Icons +- Minimalist, flat, monochrome with brand color tint +- Max 48×48px +- No drop shadows, no 3D effects + +--- + +## 9. Tier System + +Every bounty carries a tier designation. This is core to the brand and must appear consistently. + +### Tier Badges + +| Tier | Label | Color | Hex | Meaning | +|------|-------|-------|-----|---------| +| T1 | Open Race | Emerald | `#00E676` | Anyone can submit; winner takes the prize | +| T2 | Assigned | Cyan | `#40C4FF` | Maintainer selects the best submission | +| T3 | Invitational | Purple | `#7C3AED` | Invite-only; high-value | + +### Tier Visual Rules +- Always use the tier color (hex above) — never swap tiers between colors +- Tier badge shape: pill/rounded-rectangle with the tier code (e.g., "T1") +- Never use tier colors for non-tier purposes + +--- + +## 10. Do's & Don'ts + +### Logo +- ✅ Use the official logo asset file +- ✅ Maintain clearspace +- ✅ Use on dark backgrounds for best contrast +- ❌ Don't recreate the logo in Figma/sketch from scratch +- ❌ Don't use on very busy backgrounds without a clear zone + +### Color +- ✅ Use the full forge scale for dark themes +- ✅ Use emerald for primary CTAs and active states +- ✅ Use status colors consistently +- ❌ Don't use magenta as a primary background (it's too loud) +- ❌ Don't use status colors for decorative purposes + +### Typography +- ✅ Use Orbitron for display/hero headings only +- ✅ Use Inter for all body copy +- ✅ Use JetBrains Mono for addresses and hashes +- ❌ Don't use Orbitron for long paragraphs +- ❌ Don't mix more than 2 font weights in the same text hierarchy + +### Copy +- ✅ Be direct and technical — we speak to builders +- ✅ Use active voice +- ✅ Use "you" and "your" — we're on the builder's side +- ❌ Don't overhype: avoid "revolutionary", "game-changing", "best ever" +- ❌ Don't use corporate filler: "leverage", "synergy", "best-in-class" + +### Social Media +- ✅ Use the social templates in `/content/social-media-templates/` +- ✅ Match the dark theme aesthetic +- ✅ Use tier colors for bounty announcements +- ❌ Don't post with light-theme screenshots of the platform +- ❌ Don't use the platform to spam unrelated communities + +--- + +## Appendix: CSS Variables Reference + +```css +:root { + /* Brand */ + --color-emerald: #00E676; + --color-emerald-light: #69F0AE; + --color-purple: #7C3AED; + --color-purple-light: #A78BFA; + --color-magenta: #E040FB; + --color-magenta-light: #EA80FC; + + /* Forge Scale */ + --forge-950: #050505; + --forge-900: #0A0A0F; + --forge-850: #0F0F18; + --forge-800: #16161F; + --forge-700: #1E1E2A; + --forge-600: #2A2A3A; + + /* Text */ + --text-primary: #F0F0F5; + --text-secondary: #A0A0B8; + --text-muted: #5C5C78; + + /* Borders */ + --border: #1E1E2E; + --border-hover: #2E2E42; + --border-active: #3E3E56; + + /* Status */ + --status-success: #00E676; + --status-warning: #FFB300; + --status-error: #FF5252; + --status-info: #40C4FF; + + /* Tier */ + --tier-t1: #00E676; + --tier-t2: #40C4FF; + --tier-t3: #7C3AED; + + /* Fonts */ + --font-display: 'Orbitron', sans-serif; + --font-sans: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; +} +``` + +--- + +*Last updated: 2026-04-06 | Content/social-media-templates companion deliverable: [PR #942](https://github.com/SolFoundry/solfoundry/pull/942)* diff --git a/content/brand-guide/color-palette.png b/content/brand-guide/color-palette.png new file mode 100644 index 000000000..e64319a99 Binary files /dev/null and b/content/brand-guide/color-palette.png differ diff --git a/content/brand-guide/gen_brand_assets.py b/content/brand-guide/gen_brand_assets.py new file mode 100644 index 000000000..8d98b1ac8 --- /dev/null +++ b/content/brand-guide/gen_brand_assets.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Generate brand guide visual assets.""" + +import os +from PIL import Image, ImageDraw, ImageFont + +OUT = "/home/aa/.openclaw/workspace/content/brand-guide" +os.makedirs(OUT, exist_ok=True) + +# ── Brand Colors ──────────────────────────────────────────────────────────── +COLORS = { + "Emerald": ("#00E676", "#050505"), + "Emerald Light": ("#69F0AE", "#050505"), + "Purple": ("#7C3AED", "#F0F0F5"), + "Purple Light": ("#A78BFA", "#050505"), + "Magenta": ("#E040FB", "#050505"), + "Magenta Light": ("#EA80FC", "#050505"), + "Forge 950": ("#050505", "#F0F0F5"), + "Forge 900": ("#0A0A0F", "#F0F0F5"), + "Forge 850": ("#0F0F18", "#F0F0F5"), + "Forge 800": ("#16161F", "#F0F0F5"), + "Forge 700": ("#1E1E2A", "#F0F0F5"), + "Forge 600": ("#2A2A3A", "#F0F0F5"), + "Text Primary": ("#F0F0F5", "#0A0A0F"), + "Text Secondary": ("#A0A0B8", "#0A0A0F"), + "Text Muted": ("#5C5C78", "#0A0A0F"), + "T1 (Emerald)": ("#00E676", "#050505"), + "T2 (Cyan)": ("#40C4FF", "#050505"), + "T3 (Purple)": ("#7C3AED", "#F0F0F5"), +} + +def hex_to_rgb(h): + h = h.lstrip("#") + return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) + +def draw_color_palette(outfile, width=1200, swatch_h=140): + """Full-width color palette swatch.""" + n = len(COLORS) + img = Image.new("RGB", (width, n * swatch_h + 60), hex_to_rgb("#0A0A0F")) + d = ImageDraw.Draw(img) + + font_size = max(14, min(20, 900 // max(len(k) for k in COLORS))) + try: + fnt = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size) + fnt_sm = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", max(11, font_size - 3)) + except Exception: + fnt = ImageFont.load_default() + fnt_sm = fnt + + y = 0 + for name, (fg_hex, _) in COLORS.items(): + bg = hex_to_rgb(fg_hex) + d.rectangle([0, y, width, y + swatch_h], fill=bg) + + # Color name + hex code + d.text((20, y + 12), name, font=fnt, fill=(255, 255, 255, 200) if bg[0] < 180 else (0, 0, 0, 200)) + d.text((20, y + 42), fg_hex.upper(), font=fnt_sm, fill=(255, 255, 255, 130) if bg[0] < 180 else (0, 0, 0, 130)) + + # Contrast indicator + r, g, b = bg + luma = 0.299*r + 0.587*g + 0.114*b + label = "dark text" if luma > 128 else "light text" + d.text((width - 160, y + swatch_h // 2 - 8), label, font=fnt_sm, fill=(200, 200, 200)) + + y += swatch_h + + # Header + d.rectangle([0, 0, width, 55], fill=hex_to_rgb("#0F0F18")) + d.text((20, 16), "SOLFOUNDRY — Brand Color Palette", font=fnt, fill=hex_to_rgb("#00E676")) + + img.save(outfile, "PNG", optimize=True) + sz = os.path.getsize(outfile) + print(f"Palette: {outfile} ({sz//1024}KB)") + +def draw_type_specimen(outfile, width=1200): + """Typography specimen sheet.""" + img = Image.new("RGB", (width, 700), hex_to_rgb("#0A0A0F")) + d = ImageDraw.Draw(img) + + try: + # Try to find usable fonts + import subprocess + result = subprocess.run(["fc-list", ":family=Inter"], capture_output=True, text=True) + inter_paths = result.stdout.strip().split("\n") + inter_path = inter_paths[0].split(":")[0] if inter_paths else None + + result2 = subprocess.run(["fc-list", ":family=Orbitron"], capture_output=True, text=True) + orbit_paths = result2.stdout.strip().split("\n") + orbit_path = orbit_paths[0].split(":")[0] if orbit_paths else None + + result3 = subprocess.run(["fc-list", ":family=JetBrains"], capture_output=True, text=True) + mono_paths = result3.stdout.strip().split("\n") + mono_path = mono_paths[0].split(":")[0] if mono_paths else None + except Exception: + inter_path = orbit_path = mono_path = None + + def font(path, size): + if path: + try: return ImageFont.truetype(path, size) + except: pass + return ImageFont.load_default() + + fnt_head = font(orbit_path, 36) + fnt_title = font(inter_path, 26) if inter_path else font(None, 24) + fnt_body = font(inter_path, 18) if inter_path else font(None, 16) + fnt_sm = font(inter_path, 14) if inter_path else font(None, 13) + fnt_mono = font(mono_path, 16) if mono_path else font(None, 14) + + # Header + d.rectangle([0, 0, width, 55], fill=hex_to_rgb("#0F0F18")) + d.text((20, 16), "TYPOGRAPHY SPECIMEN", font=fnt_head, fill=hex_to_rgb("#00E676")) + + y = 75 + + # Orbitron specimen + d.text((20, y), "Orbitron — Display / Hero", font=fnt_sm, fill=hex_to_rgb("#A0A0B8")) + y += 32 + d.text((20, y), "BUILD THE OPEN WEB", font=fnt_head, fill=hex_to_rgb("#F0F0F5")) + y += 56 + d.text((20, y), "ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 !@#$%", font=fnt_body, fill=hex_to_rgb("#A0A0B8")) + y += 45 + + # Divider + d.line([(20, y), (width-20, y)], fill=hex_to_rgb("#1E1E2A"), width=1) + y += 20 + + # Inter specimen + d.text((20, y), "Inter — Body / UI", font=fnt_sm, fill=hex_to_rgb("#A0A0B8")) + y += 32 + d.text((20, y), "SolFoundry is where builders ship, earn, and grow.", font=fnt_title, fill=hex_to_rgb("#F0F0F5")) + y += 38 + sample = "The quick brown fox jumps over the lazy dog. 0123456789" + d.text((20, y), sample, font=fnt_body, fill=hex_to_rgb("#A0A0B8")) + y += 30 + d.text((20, y), sample.upper(), font=fnt_sm, fill=hex_to_rgb("#5C5C78")) + y += 45 + + d.line([(20, y), (width-20, y)], fill=hex_to_rgb("#1E1E2A"), width=1) + y += 20 + + # JetBrains Mono specimen + d.text((20, y), "JetBrains Mono — Code / Addresses", font=fnt_sm, fill=hex_to_rgb("#A0A0B8")) + y += 32 + mono_sample = "7UqBdYyy9LG59Un6yzjAW8HPcTC4J63B9cZxBHWhhBHPXK7Y 0x1a2b...9f3c tx: 4jKY93..." + d.text((20, y), mono_sample, font=fnt_mono, fill=hex_to_rgb("#00E676")) + y += 30 + code_sample = "const bounty = await api.getBounty({ id: '#827' });" + d.text((20, y), code_sample, font=fnt_mono, fill=hex_to_rgb("#A78BFA")) + + img.save(outfile, "PNG", optimize=True) + sz = os.path.getsize(outfile) + print(f"Typography: {outfile} ({sz//1024}KB)") + +def draw_tier_badges(outfile, width=800): + """Tier badge showcase.""" + img = Image.new("RGB", (width, 300), hex_to_rgb("#0A0A0F")) + d = ImageDraw.Draw(img) + + tiers = [ + ("T1", "#00E676", "Open Race"), + ("T2", "#40C4FF", "Assigned"), + ("T3", "#7C3AED", "Invitational"), + ] + + x = 40 + for label, color, desc in tiers: + c = hex_to_rgb(color) + # Badge + d.rounded_rectangle([x, 40, x+100, 100], radius=50, fill=c) + fnt = ImageFont.load_default() + try: + fnt = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36) + except: + pass + d.text((x+50, 56), label, font=fnt, fill=(0, 0, 0), anchor="mm") + # Label below + fnt_sm = ImageFont.load_default() + d.text((x+50, 120), desc, font=fnt_sm, fill=hex_to_rgb("#A0A0B8"), anchor="mm") + # Hex + d.text((x+50, 145), color, font=fnt_sm, fill=hex_to_rgb("#5C5C78"), anchor="mm") + x += 160 + + # Header + d.rectangle([0, 0, width, 35], fill=hex_to_rgb("#0F0F18")) + d.text((20, 10), "TIER BADGES", font=ImageFont.load_default(), fill=hex_to_rgb("#00E676")) + + img.save(outfile, "PNG", optimize=True) + sz = os.path.getsize(outfile) + print(f"Tier badges: {outfile} ({sz//1024}KB)") + + +if __name__ == "__main__": + draw_color_palette(f"{OUT}/color-palette.png") + draw_type_specimen(f"{OUT}/typography-specimen.png") + draw_tier_badges(f"{OUT}/tier-badges.png") + print("All brand assets generated.") diff --git a/content/brand-guide/tier-badges.png b/content/brand-guide/tier-badges.png new file mode 100644 index 000000000..c9f8cd99c Binary files /dev/null and b/content/brand-guide/tier-badges.png differ diff --git a/content/brand-guide/typography-specimen.png b/content/brand-guide/typography-specimen.png new file mode 100644 index 000000000..adf7d0e03 Binary files /dev/null and b/content/brand-guide/typography-specimen.png differ diff --git a/content/social-media-templates/README.md b/content/social-media-templates/README.md new file mode 100644 index 000000000..cf430e9ae --- /dev/null +++ b/content/social-media-templates/README.md @@ -0,0 +1,93 @@ +# SolFoundry Social Media Templates + +5 reusable social media templates for announcing bounties on X/Twitter. Matches SolFoundry brand: dark theme, emerald/magenta accents, forge aesthetic. + +## Deliverables + +### Format Variants +- **Feed posts** — `t{N}_feed.svg` / `t{N}_feed.png` (1080×1080px) — square format for X/Twitter feed +- **Twitter cards** — `t{N}_card.svg` / `t{N}_card.png` (1200×675px) — landscape for link preview cards + +### Template Themes +| # | Feed (1080×1080) | Card (1200×675) | Tier | +|---|-----------------|-----------------|------| +| 1 | NEW BOUNTY ALERT | Emerald gradient | T1 | +| 2 | FLASH BOUNTY | Purple gradient | T2 | +| 3 | COMMUNITY PICKS | Cyan gradient | T1 | +| 4 | HOT BOUNTY | Gold gradient | T3 | +| 5 | LAST CALL | Magenta gradient | T2 | + +## Editing Templates + +### Option 1 — Edit SVG directly (recommended) +Open any `.svg` file in: +- **Figma** (import SVG) +- **Canva** (import as custom size) +- **Inkscape** (free, open source) +- **Adobe Illustrator** + +### Option 2 — Regenerate with Python +```bash +pip install svgwrite cairosvg pillow +python gen_templates.py +``` +Edit the `templates` list in `gen_templates.py` to customize title, reward, and tier. + +### Option 3 — Edit PNG in Canva +Upload `t{N}_feed.png` to [Canva](https://canva.com) → select "Use custom dimensions" → edit text overlays. + +## Design System + +### Brand Colors +| Name | Hex | Usage | +|------|-----|-------| +| Background | `#0d0d0f` | Page background | +| Surface | `#1a1a1f` | Card backgrounds | +| Emerald | `#10b981` | Primary accent, T1 badges, CTAs | +| Magenta | `#ec4899` | Secondary accent | +| Muted | `#6b7280` | Placeholder text, secondary text | +| Border | `#2a2a35` | Subtle borders | +| T2 Purple | `#8b5cf6` | T2 tier badge | +| T3 Gold | `#f59e0b` | T3 tier badge | + +### Typography +- **Primary font**: Inter (Google Fonts) or Arial fallback +- **Title text**: 26px bold +- **Reward amount**: 40–48px bold, emerald +- **Labels**: 12–14px, uppercase, letter-spacing 2–3px +- **CTA text**: 14–15px bold, uppercase + +### Layout +- Rounded corners: 16px for cards, 8px for inner elements +- Padding: 40px outer margin +- Gradient accent bars at top and bottom (emerald → cyan) + +## Acceptance Criteria +- [x] 5 templates delivered in PNG + SVG source format +- [x] Match SolFoundry brand guidelines (dark theme, emerald/magenta accents) +- [x] Editable for future bounty announcements (SVG + Python script) +- [x] 1080×1080 feed format + 1200×675 card format +- [x] Placeholder zones for bounty title, reward amount, description + +## File Structure +``` +content/social-media-templates/ +├── README.md ← This file +├── gen_templates.py ← Python generator script +├── t1_feed.svg/png ← Template 1: Feed (1080×1080) +├── t1_card.svg/png ← Template 1: Card (1200×675) +├── t2_feed.svg/png +├── t2_card.svg/png +├── t3_feed.svg/png +├── t3_card.svg/png +├── t4_feed.svg/png +├── t4_card.svg/png +├── t5_feed.svg/png +└── t5_card.svg/png +``` + +## Notes +- SVG files are fully vector — infinitely scalable +- Text placeholders use `[ BOUNTY TITLE ]` / `[ Describe the bounty ]` format +- SolFoundry logo placeholder is the "SOLFOUNDRY" wordmark text — replace with official logo asset +- Gradient bars use emerald (#10b981) → cyan (#06b6d4) linear gradient diff --git a/content/social-media-templates/gen_templates.py b/content/social-media-templates/gen_templates.py new file mode 100644 index 000000000..225202953 --- /dev/null +++ b/content/social-media-templates/gen_templates.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Generate SolFoundry social media templates as SVG files.""" + +import os + +OUT = "/home/aa/.openclaw/workspace/projects/solfoundry-work/templates" +os.makedirs(OUT, exist_ok=True) + +BRAND = { + 'bg': '#0d0d0f', + 'surface': '#1a1a1f', + 'emerald': '#10b981', + 'magenta': '#ec4899', + 'white': '#ffffff', + 'muted': '#6b7280', + 'border': '#2a2a35', +} + +def gstop(g, offset, color, opacity=1.0): + g.add_stop_color(offset, color, opacity=opacity) + +def make_feed(i, title, reward, tier, outfile): + """1080x1080 feed post template""" + import svgwrite + d = svgwrite.Drawing(outfile, profile='full', size=('1080', '1080')) + d['style'] = 'background:#0d0d0f' + + # Gradient def + tg = d.linearGradient(('0%','0%'),('100%','0%'), id='topGrad') + tg.add_stop_color(0, BRAND['emerald']) + tg.add_stop_color(1, '#06b6d4') + d.defs.add(tg) + + rg = d.linearGradient(('0%','0%'),('100%','0%'), id='rewardGrad') + rg.add_stop_color(0, BRAND['emerald'], opacity=0.4) + rg.add_stop_color(1, '#06b6d4', opacity=0.4) + d.defs.add(rg) + + tier_color = {'T1': BRAND['emerald'], 'T2': '#8b5cf6', 'T3': '#f59e0b'}.get(tier, BRAND['emerald']) + + # Background + d.add(d.rect(insert=(0,0), size=('1080','1080'), fill=BRAND['bg'])) + # Top gradient bar + d.add(d.rect(insert=(0,0), size=('1080','8'), fill='url(#topGrad)')) + # Header bar + d.add(d.rect(insert=(0,8), size=('1080','80'), fill=BRAND['surface'])) + # Logo text + d.add(d.text('SOLFOUNDRY', insert=(48,62), fill=BRAND['emerald'], + font_family='Inter, Arial, sans-serif', font_size='20', font_weight='bold', letter_spacing='3')) + # Tier badge + d.add(d.rect(insert=(880,24), size=(160,48), rx='24', fill=tier_color)) + d.add(d.text(tier, insert=(960,55), fill='white', + font_family='Inter, Arial, sans-serif', font_size='18', font_weight='bold', text_anchor='middle')) + # Content card + d.add(d.rect(insert=(40,110), size=('1000','700'), rx='16', + fill=BRAND['surface'], stroke=BRAND['border'], stroke_width='1')) + # Template number + d.add(d.text(f'TEMPLATE {i+1}', insert=(60,155), + fill=BRAND['muted'], font_family='monospace', font_size='13', letter_spacing='2')) + # Title placeholder box + d.add(d.rect(insert=(60,175), size=('960','90'), rx='8', + fill=BRAND['bg'], stroke=BRAND['emerald'], stroke_width='1', stroke_dasharray='6,4')) + d.add(d.text('[ BOUNTY TITLE ]', insert=(540,225), + fill=BRAND['muted'], font_family='Inter, Arial, sans-serif', + font_size='26', text_anchor='middle', font_style='italic')) + # Reward box + d.add(d.rect(insert=(240,295), size=('600','110'), rx='12', + fill='url(#rewardGrad)', stroke=BRAND['emerald'], stroke_width='2')) + d.add(d.text('REWARD', insert=(540,332), + fill=BRAND['muted'], font_family='Inter, Arial, sans-serif', + font_size='13', text_anchor='middle', letter_spacing='3')) + d.add(d.text(reward, insert=(540,390), + fill=BRAND['emerald'], font_family='Inter, Arial, sans-serif', + font_size='48', font_weight='bold', text_anchor='middle')) + # Divider + d.add(d.line((120,440),(960,440), stroke=BRAND['border'], stroke_width='1')) + # Description placeholder + d.add(d.rect(insert=(60,460), size=('960','80'), rx='8', + fill=BRAND['bg'], stroke=BRAND['border'], stroke_width='1', stroke_dasharray='4,4')) + d.add(d.text('[ One-sentence bounty description ]', insert=(540,505), + fill=BRAND['muted'], font_family='Inter, Arial, sans-serif', + font_size='18', text_anchor='middle', font_style='italic')) + # CTA section + d.add(d.rect(insert=(40,810), size=('1000','250'), rx='16', + fill=BRAND['surface'], stroke=BRAND['border'], stroke_width='1')) + d.add(d.text('⚒', insert=(540,870), fill=BRAND['emerald'], + font_family='Arial', font_size='48', text_anchor='middle')) + d.add(d.text('SOLVE THIS BOUNTY', insert=(540,930), + fill='white', font_family='Inter, Arial, sans-serif', + font_size='26', font_weight='bold', text_anchor='middle', letter_spacing='1')) + d.add(d.text('github.com/solfoundry/solfoundry', insert=(540,975), + fill=BRAND['muted'], font_family='Inter, Arial, sans-serif', + font_size='14', text_anchor='middle')) + d.add(d.text('#BountyHunting #Web3 #Solana', insert=(540,1015), + fill=BRAND['emerald'], font_family='Inter, Arial, sans-serif', + font_size='14', text_anchor='middle', letter_spacing='1')) + # Footer gradient + d.add(d.rect(insert=(0,1072), size=('1080','8'), fill='url(#topGrad)')) + d.save() + print(f"Saved: {outfile}") + + +def make_card(i, title, reward, tier, outfile): + """1200x675 Twitter/X card template""" + import svgwrite + d = svgwrite.Drawing(outfile, profile='full', size=('1200', '675')) + d['style'] = 'background:#0d0d0f' + + tg = d.linearGradient(('0%','0%'),('100%','0%'), id='topGrad') + tg.add_stop_color(0, BRAND['emerald']) + tg.add_stop_color(1, '#06b6d4') + d.defs.add(tg) + + tier_color = {'T1': BRAND['emerald'], 'T2': '#8b5cf6', 'T3': '#f59e0b'}.get(tier, BRAND['emerald']) + + # Background + d.add(d.rect(insert=(0,0), size=('1200','675'), fill=BRAND['bg'])) + d.add(d.rect(insert=(0,667), size=('1200','8'), fill='url(#topGrad)')) + + # Header + d.add(d.rect(insert=(0,0), size=('1200','80'), fill=BRAND['surface'])) + d.add(d.text('SOLFOUNDRY', insert=(40,52), fill=BRAND['emerald'], + font_family='Inter, Arial, sans-serif', font_size='18', font_weight='bold', letter_spacing='3')) + d.add(d.rect(insert=(980,22), size=(180,46), rx='23', fill=tier_color)) + d.add(d.text(tier, insert=(1070,52), fill='white', + font_family='Inter, Arial, sans-serif', font_size='16', font_weight='bold', text_anchor='middle')) + + # Content card + d.add(d.rect(insert=(40,100), size=('1120','540'), rx='16', + fill=BRAND['surface'], stroke=BRAND['border'], stroke_width='1')) + + # Title placeholder + d.add(d.rect(insert=(80,130), size=('1040','80'), rx='8', + fill=BRAND['bg'], stroke=BRAND['emerald'], stroke_width='1', stroke_dasharray='6,4')) + d.add(d.text('[ BOUNTY TITLE ]', insert=(600,175), + fill=BRAND['muted'], font_family='Inter, Arial, sans-serif', + font_size='22', text_anchor='middle', font_style='italic')) + + # Reward highlight + d.add(d.rect(insert=(350,245), size=('500','95'), rx='12', + fill=tier_color, opacity=0.15, stroke=tier_color, stroke_width='2')) + d.add(d.text('REWARD', insert=(600,275), + fill=BRAND['muted'], font_family='Inter, Arial, sans-serif', + font_size='12', text_anchor='middle', letter_spacing='3')) + d.add(d.text(reward, insert=(600,325), + fill=BRAND['emerald'], font_family='Inter, Arial, sans-serif', + font_size='40', font_weight='bold', text_anchor='middle')) + + # Description placeholder + d.add(d.rect(insert=(80,370), size=('1040','60'), rx='8', + fill=BRAND['bg'], stroke=BRAND['border'], stroke_width='1', stroke_dasharray='4,4')) + d.add(d.text('[ Describe the bounty in one compelling sentence ]', + insert=(600,408), fill=BRAND['muted'], + font_family='Inter, Arial, sans-serif', font_size='17', text_anchor='middle', font_style='italic')) + + # CTA button + d.add(d.rect(insert=(440,465), size=('320','52'), rx='26', fill=BRAND['emerald'])) + d.add(d.text('VIEW BOUNTY →', insert=(600,497), + fill='white', font_family='Inter, Arial, sans-serif', + font_size='15', font_weight='bold', text_anchor='middle')) + + # Hashtags + d.add(d.text('#BountyHunting #Web3 #Solana', insert=(600,560), + fill=BRAND['emerald'], font_family='Inter, Arial, sans-serif', + font_size='14', text_anchor='middle', letter_spacing='1')) + + d.save() + print(f"Saved: {outfile}") + + +if __name__ == '__main__': + templates = [ + ('NEW BOUNTY ALERT', '150K $FNDRY', 'T1'), + ('FLASH BOUNTY', '500K $FNDRY', 'T2'), + ('COMMUNITY PICKS', '200K $FNDRY', 'T1'), + ('HOT BOUNTY', '1M $FNDRY', 'T3'), + ('LAST CALL', '300K $FNDRY', 'T2'), + ] + + for i, (title, reward, tier) in enumerate(templates): + make_feed(i, title, reward, tier, f'{OUT}/t{i+1}_feed.svg') + make_card(i, title, reward, tier, f'{OUT}/t{i+1}_card.svg') + + print(f'\nAll 10 SVG files generated in {OUT}') diff --git a/content/social-media-templates/t1_card.png b/content/social-media-templates/t1_card.png new file mode 100644 index 000000000..e33452cd1 Binary files /dev/null and b/content/social-media-templates/t1_card.png differ diff --git a/content/social-media-templates/t1_card.svg b/content/social-media-templates/t1_card.svg new file mode 100644 index 000000000..cc3934ead --- /dev/null +++ b/content/social-media-templates/t1_card.svg @@ -0,0 +1,2 @@ + +SOLFOUNDRYT1[ BOUNTY TITLE ]REWARD150K $FNDRY[ Describe the bounty in one compelling sentence ]VIEW BOUNTY →#BountyHunting #Web3 #Solana \ No newline at end of file diff --git a/content/social-media-templates/t1_feed.png b/content/social-media-templates/t1_feed.png new file mode 100644 index 000000000..17237fb60 Binary files /dev/null and b/content/social-media-templates/t1_feed.png differ diff --git a/content/social-media-templates/t1_feed.svg b/content/social-media-templates/t1_feed.svg new file mode 100644 index 000000000..2c37b2610 --- /dev/null +++ b/content/social-media-templates/t1_feed.svg @@ -0,0 +1,2 @@ + +SOLFOUNDRYT1TEMPLATE 1[ BOUNTY TITLE ]REWARD150K $FNDRY[ One-sentence bounty description ]SOLVE THIS BOUNTYgithub.com/solfoundry/solfoundry#BountyHunting #Web3 #Solana \ No newline at end of file diff --git a/content/social-media-templates/t2_card.png b/content/social-media-templates/t2_card.png new file mode 100644 index 000000000..c652b698c Binary files /dev/null and b/content/social-media-templates/t2_card.png differ diff --git a/content/social-media-templates/t2_card.svg b/content/social-media-templates/t2_card.svg new file mode 100644 index 000000000..4fbbe164d --- /dev/null +++ b/content/social-media-templates/t2_card.svg @@ -0,0 +1,2 @@ + +SOLFOUNDRYT2[ BOUNTY TITLE ]REWARD500K $FNDRY[ Describe the bounty in one compelling sentence ]VIEW BOUNTY →#BountyHunting #Web3 #Solana \ No newline at end of file diff --git a/content/social-media-templates/t2_feed.png b/content/social-media-templates/t2_feed.png new file mode 100644 index 000000000..02f12b898 Binary files /dev/null and b/content/social-media-templates/t2_feed.png differ diff --git a/content/social-media-templates/t2_feed.svg b/content/social-media-templates/t2_feed.svg new file mode 100644 index 000000000..2e77594ee --- /dev/null +++ b/content/social-media-templates/t2_feed.svg @@ -0,0 +1,2 @@ + +SOLFOUNDRYT2TEMPLATE 2[ BOUNTY TITLE ]REWARD500K $FNDRY[ One-sentence bounty description ]SOLVE THIS BOUNTYgithub.com/solfoundry/solfoundry#BountyHunting #Web3 #Solana \ No newline at end of file diff --git a/content/social-media-templates/t3_card.png b/content/social-media-templates/t3_card.png new file mode 100644 index 000000000..21717bb6f Binary files /dev/null and b/content/social-media-templates/t3_card.png differ diff --git a/content/social-media-templates/t3_card.svg b/content/social-media-templates/t3_card.svg new file mode 100644 index 000000000..92761d601 --- /dev/null +++ b/content/social-media-templates/t3_card.svg @@ -0,0 +1,2 @@ + +SOLFOUNDRYT1[ BOUNTY TITLE ]REWARD200K $FNDRY[ Describe the bounty in one compelling sentence ]VIEW BOUNTY →#BountyHunting #Web3 #Solana \ No newline at end of file diff --git a/content/social-media-templates/t3_feed.png b/content/social-media-templates/t3_feed.png new file mode 100644 index 000000000..f56e53708 Binary files /dev/null and b/content/social-media-templates/t3_feed.png differ diff --git a/content/social-media-templates/t3_feed.svg b/content/social-media-templates/t3_feed.svg new file mode 100644 index 000000000..f0166c04d --- /dev/null +++ b/content/social-media-templates/t3_feed.svg @@ -0,0 +1,2 @@ + +SOLFOUNDRYT1TEMPLATE 3[ BOUNTY TITLE ]REWARD200K $FNDRY[ One-sentence bounty description ]SOLVE THIS BOUNTYgithub.com/solfoundry/solfoundry#BountyHunting #Web3 #Solana \ No newline at end of file diff --git a/content/social-media-templates/t4_card.png b/content/social-media-templates/t4_card.png new file mode 100644 index 000000000..5df728ac6 Binary files /dev/null and b/content/social-media-templates/t4_card.png differ diff --git a/content/social-media-templates/t4_card.svg b/content/social-media-templates/t4_card.svg new file mode 100644 index 000000000..b74ef5af4 --- /dev/null +++ b/content/social-media-templates/t4_card.svg @@ -0,0 +1,2 @@ + +SOLFOUNDRYT3[ BOUNTY TITLE ]REWARD1M $FNDRY[ Describe the bounty in one compelling sentence ]VIEW BOUNTY →#BountyHunting #Web3 #Solana \ No newline at end of file diff --git a/content/social-media-templates/t4_feed.png b/content/social-media-templates/t4_feed.png new file mode 100644 index 000000000..14af122db Binary files /dev/null and b/content/social-media-templates/t4_feed.png differ diff --git a/content/social-media-templates/t4_feed.svg b/content/social-media-templates/t4_feed.svg new file mode 100644 index 000000000..1881df626 --- /dev/null +++ b/content/social-media-templates/t4_feed.svg @@ -0,0 +1,2 @@ + +SOLFOUNDRYT3TEMPLATE 4[ BOUNTY TITLE ]REWARD1M $FNDRY[ One-sentence bounty description ]SOLVE THIS BOUNTYgithub.com/solfoundry/solfoundry#BountyHunting #Web3 #Solana \ No newline at end of file diff --git a/content/social-media-templates/t5_card.png b/content/social-media-templates/t5_card.png new file mode 100644 index 000000000..e57ba553b Binary files /dev/null and b/content/social-media-templates/t5_card.png differ diff --git a/content/social-media-templates/t5_card.svg b/content/social-media-templates/t5_card.svg new file mode 100644 index 000000000..2ec5fa423 --- /dev/null +++ b/content/social-media-templates/t5_card.svg @@ -0,0 +1,2 @@ + +SOLFOUNDRYT2[ BOUNTY TITLE ]REWARD300K $FNDRY[ Describe the bounty in one compelling sentence ]VIEW BOUNTY →#BountyHunting #Web3 #Solana \ No newline at end of file diff --git a/content/social-media-templates/t5_feed.png b/content/social-media-templates/t5_feed.png new file mode 100644 index 000000000..4c33fa864 Binary files /dev/null and b/content/social-media-templates/t5_feed.png differ diff --git a/content/social-media-templates/t5_feed.svg b/content/social-media-templates/t5_feed.svg new file mode 100644 index 000000000..f05e6ea60 --- /dev/null +++ b/content/social-media-templates/t5_feed.svg @@ -0,0 +1,2 @@ + +SOLFOUNDRYT2TEMPLATE 5[ BOUNTY TITLE ]REWARD300K $FNDRY[ One-sentence bounty description ]SOLVE THIS BOUNTYgithub.com/solfoundry/solfoundry#BountyHunting #Web3 #Solana \ No newline at end of file diff --git a/frontend/src/__tests__/activity-feed-home.test.tsx b/frontend/src/__tests__/activity-feed-home.test.tsx new file mode 100644 index 000000000..372d3cb77 --- /dev/null +++ b/frontend/src/__tests__/activity-feed-home.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ActivityFeed } from '../components/home/ActivityFeed'; + +const useActivityMock = vi.fn(); + +vi.mock('../hooks/useActivity', () => ({ + useActivity: () => useActivityMock(), +})); + +vi.mock('../lib/animations', () => ({ + slideInRight: { initial: {}, animate: {} }, +})); + +describe('home activity feed', () => { + beforeEach(() => { + useActivityMock.mockReset(); + }); + + it('renders real activity events from the hook', () => { + useActivityMock.mockReturnValue({ + data: [ + { + id: 'evt-1', + type: 'submitted', + username: 'alice', + detail: 'PR to Bounty #12', + timestamp: new Date().toISOString(), + }, + ], + isLoading: false, + isError: false, + }); + + render(); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText(/PR to Bounty #12/)).toBeInTheDocument(); + }); + + it('shows no recent activity state when API returns empty', () => { + useActivityMock.mockReturnValue({ data: [], isLoading: false, isError: false }); + render(); + expect(screen.getByText('No recent activity')).toBeInTheDocument(); + }); + + it('falls back gracefully when API is unavailable', () => { + useActivityMock.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + render(); + expect(screen.getByText(/showing fallback activity/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/activity.ts b/frontend/src/api/activity.ts new file mode 100644 index 000000000..1beabea99 --- /dev/null +++ b/frontend/src/api/activity.ts @@ -0,0 +1,45 @@ +import { apiClient } from '../services/apiClient'; + +export interface ActivityEvent { + id: string; + type: 'completed' | 'submitted' | 'posted' | 'review' | 'payout'; + username: string; + avatar_url?: string | null; + detail: string; + timestamp: string; +} + +function normalizeType(type?: string): ActivityEvent['type'] { + switch (type) { + case 'completed': + case 'submitted': + case 'posted': + case 'review': + case 'payout': + return type; + default: + return 'posted'; + } +} + +function mapEvent(raw: Record, index: number): ActivityEvent { + return { + id: String(raw.id ?? `activity-${index}`), + type: normalizeType(String(raw.type ?? raw.event_type ?? 'posted')), + username: String(raw.username ?? raw.actor_username ?? raw.user ?? 'Unknown'), + avatar_url: (raw.avatar_url as string | null | undefined) ?? (raw.actor_avatar_url as string | null | undefined) ?? null, + detail: String(raw.detail ?? raw.message ?? raw.summary ?? 'Activity updated'), + timestamp: String(raw.timestamp ?? raw.created_at ?? raw.occurred_at ?? new Date().toISOString()), + }; +} + +export async function getRecentActivity(): Promise { + const response = await apiClient[]; events?: Record[] }>('/api/activity'); + + if (Array.isArray(response)) { + return response.map((item, index) => mapEvent(item as unknown as Record, index)); + } + + const items = response.items ?? response.events ?? []; + return items.map((item, index) => mapEvent(item, index)); +} diff --git a/frontend/src/components/home/ActivityFeed.tsx b/frontend/src/components/home/ActivityFeed.tsx index 8b6b4b904..d719660e4 100644 --- a/frontend/src/components/home/ActivityFeed.tsx +++ b/frontend/src/components/home/ActivityFeed.tsx @@ -1,19 +1,11 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { slideInRight } from '../../lib/animations'; import { timeAgo } from '../../lib/utils'; +import { useActivity } from '../../hooks/useActivity'; +import type { ActivityEvent } from '../../api/activity'; -interface ActivityEvent { - id: string; - type: 'completed' | 'submitted' | 'posted' | 'review'; - username: string; - avatar_url?: string | null; - detail: string; - timestamp: string; -} - -// Mock events for when API doesn't return activity -const MOCK_EVENTS: ActivityEvent[] = [ +const FALLBACK_EVENTS: ActivityEvent[] = [ { id: '1', type: 'completed', @@ -30,34 +22,34 @@ const MOCK_EVENTS: ActivityEvent[] = [ }, { id: '3', - type: 'posted', - username: 'SolanaLabs', - detail: 'Bounty #145 — $3,500 USDC', + type: 'payout', + username: 'SolFoundry', + detail: 'FNDRY payout released', timestamp: new Date(Date.now() - 45 * 60 * 1000).toISOString(), }, - { - id: '4', - type: 'review', - username: 'AI Review', - detail: 'Bounty #42 — 8.5/10', - timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), - }, ]; function getActionText(type: ActivityEvent['type']) { switch (type) { - case 'completed': return 'earned'; - case 'submitted': return 'submitted'; - case 'posted': return 'posted'; - case 'review': return 'AI Review passed for'; - default: return 'updated'; + case 'completed': + return 'earned'; + case 'submitted': + return 'submitted'; + case 'posted': + return 'posted'; + case 'review': + return 'reviewed'; + case 'payout': + return 'received'; + default: + return 'updated'; } } function EventItem({ event }: { event: ActivityEvent }) { - const isMagenta = event.type === 'review'; + const isAccent = event.type === 'review' ? 'text-magenta' : 'text-emerald'; return ( -
+
{event.avatar_url ? ( ) : ( @@ -65,23 +57,20 @@ function EventItem({ event }: { event: ActivityEvent }) { {event.username[0]?.toUpperCase()}
)} -

- {event.username} - {' '}{getActionText(event.type)}{' '} - {event.detail} +

+ {event.username}{' '} + {getActionText(event.type)}{' '} + {event.detail}

{timeAgo(event.timestamp)}
); } -export function ActivityFeed({ events }: { events?: ActivityEvent[] }) { - const displayEvents = events?.length ? events.slice(0, 4) : MOCK_EVENTS; - const [visibleEvents, setVisibleEvents] = useState(displayEvents.slice(0, 4)); - - useEffect(() => { - setVisibleEvents(displayEvents.slice(0, 4)); - }, [events]); +export function ActivityFeed() { + const { data, isLoading, isError } = useActivity(); + const displayEvents = isError ? FALLBACK_EVENTS : data ?? []; + const visibleEvents = displayEvents.slice(0, 4); return (
@@ -90,22 +79,51 @@ export function ActivityFeed({ events }: { events?: ActivityEvent[] }) { Recent Activity -
- - {visibleEvents.map((event) => ( - - - + + {isLoading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
))} - -
+
+ )} + + {!isLoading && visibleEvents.length === 0 && !isError && ( +
No recent activity
+ )} + + {!isLoading && visibleEvents.length > 0 && ( +
+ + {visibleEvents.map((event) => ( + + + + ))} + +
+ )} + + {isError && ( +
API unavailable — showing fallback activity.
+ )}
); diff --git a/frontend/src/hooks/useActivity.ts b/frontend/src/hooks/useActivity.ts new file mode 100644 index 000000000..a987f789c --- /dev/null +++ b/frontend/src/hooks/useActivity.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { getRecentActivity } from '../api/activity'; + +export function useActivity() { + return useQuery({ + queryKey: ['recent-activity'], + queryFn: getRecentActivity, + staleTime: 15_000, + refetchInterval: 30_000, + }); +}