From 452c5555388e5737b1a5b453f4291b9a569579ad Mon Sep 17 00:00:00 2001 From: teslims2 <38410456+teslims2@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:36:41 +0000 Subject: [PATCH] feat: implement social sharing, data export, referral program, and search enhancements - closes #826: Add ShareButton component to Web3 Learning Roadmap with Twitter/X, LinkedIn, and copy-link sharing - closes #824: Add Data Export Feature for Open Source Contribution Trainer (JSON/CSV export service + routes + tests) - closes #827: Add Referral Program System as Soroban contract (register_referral, get_referral_count, get_referrer) - closes #828: Add ContractSearch component to Smart Contract Playground with text search and type filter --- .../src/routes/contribution-export.routes.ts | 22 ++++ .../src/services/contributionExportService.ts | 38 +++++++ backend/tests/contributionExport.test.ts | 53 +++++++++ contracts/src/lib.rs | 1 + contracts/src/referral_program.rs | 103 ++++++++++++++++++ frontend/src/app/playground/page.tsx | 5 +- frontend/src/app/roadmap/page.tsx | 2 + .../playground/ContractSearch.test.tsx | 32 ++++++ .../components/playground/ContractSearch.tsx | 85 +++++++++++++++ .../components/roadmap/ShareButton.test.tsx | 52 +++++++++ .../src/components/roadmap/ShareButton.tsx | 60 ++++++++++ 11 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/contribution-export.routes.ts create mode 100644 backend/src/services/contributionExportService.ts create mode 100644 backend/tests/contributionExport.test.ts create mode 100644 contracts/src/referral_program.rs create mode 100644 frontend/src/components/playground/ContractSearch.test.tsx create mode 100644 frontend/src/components/playground/ContractSearch.tsx create mode 100644 frontend/src/components/roadmap/ShareButton.test.tsx create mode 100644 frontend/src/components/roadmap/ShareButton.tsx diff --git a/backend/src/routes/contribution-export.routes.ts b/backend/src/routes/contribution-export.routes.ts new file mode 100644 index 00000000..f9ddade6 --- /dev/null +++ b/backend/src/routes/contribution-export.routes.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { exportAsJSON, exportAsCSV } from '../services/contributionExportService.js'; + +const router = Router(); + +// GET /export/contributions/:userId/json +router.get('/contributions/:userId/json', (req, res) => { + const { userId } = req.params; + const data = exportAsJSON(userId); + res.json(data); +}); + +// GET /export/contributions/:userId/csv +router.get('/contributions/:userId/csv', (req, res) => { + const { userId } = req.params; + const csv = exportAsCSV(userId); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="contributions-${userId}.csv"`); + res.send(csv); +}); + +export default router; diff --git a/backend/src/services/contributionExportService.ts b/backend/src/services/contributionExportService.ts new file mode 100644 index 00000000..268d96f7 --- /dev/null +++ b/backend/src/services/contributionExportService.ts @@ -0,0 +1,38 @@ +interface Contribution { + id: string; + repo: string; + type: string; + date: string; + status: string; +} + +interface ContributionExportData { + userId: string; + exportedAt: string; + contributions: Contribution[]; +} + +function getMockContributions(): Contribution[] { + return [ + { id: 'c1', repo: 'stellar/stellar-sdk', type: 'pull_request', date: '2024-03-01', status: 'merged' }, + { id: 'c2', repo: 'web3-student-lab/frontend', type: 'issue', date: '2024-03-15', status: 'closed' }, + { id: 'c3', repo: 'soroban-examples/tokens', type: 'pull_request', date: '2024-04-10', status: 'open' }, + ]; +} + +export function exportAsJSON(userId: string): ContributionExportData { + return { + userId, + exportedAt: new Date().toISOString(), + contributions: getMockContributions(), + }; +} + +export function exportAsCSV(userId: string): string { + const contributions = getMockContributions(); + const header = 'id,repo,type,date,status'; + const rows = contributions.map( + (c) => `${c.id},${c.repo},${c.type},${c.date},${c.status}` + ); + return [header, ...rows].join('\n'); +} diff --git a/backend/tests/contributionExport.test.ts b/backend/tests/contributionExport.test.ts new file mode 100644 index 00000000..cfca8631 --- /dev/null +++ b/backend/tests/contributionExport.test.ts @@ -0,0 +1,53 @@ +import request from 'supertest'; +import express from 'express'; +import { exportAsJSON, exportAsCSV } from '../src/services/contributionExportService.js'; +import contributionExportRouter from '../src/routes/contribution-export.routes.js'; + +describe('contributionExportService', () => { + describe('exportAsJSON', () => { + it('returns the correct data shape', () => { + const result = exportAsJSON('user-42'); + expect(result.userId).toBe('user-42'); + expect(typeof result.exportedAt).toBe('string'); + expect(new Date(result.exportedAt).toISOString()).toBe(result.exportedAt); + expect(Array.isArray(result.contributions)).toBe(true); + expect(result.contributions).toHaveLength(3); + const [first] = result.contributions; + expect(first).toHaveProperty('id'); + expect(first).toHaveProperty('repo'); + expect(first).toHaveProperty('type'); + expect(first).toHaveProperty('date'); + expect(first).toHaveProperty('status'); + }); + }); + + describe('exportAsCSV', () => { + it('returns a string with CSV headers', () => { + const result = exportAsCSV('user-42'); + expect(typeof result).toBe('string'); + const lines = result.split('\n'); + expect(lines[0]).toBe('id,repo,type,date,status'); + expect(lines).toHaveLength(4); // header + 3 rows + }); + }); +}); + +describe('contribution export routes', () => { + const app = express(); + app.use('/export', contributionExportRouter); + + it('GET /export/contributions/:userId/json returns 200 with JSON content type', async () => { + const res = await request(app).get('/export/contributions/user-1/json'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.body.userId).toBe('user-1'); + }); + + it('GET /export/contributions/:userId/csv returns 200 with CSV content type', async () => { + const res = await request(app).get('/export/contributions/user-1/csv'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/csv/); + expect(res.headers['content-disposition']).toMatch(/attachment/); + expect(res.text).toMatch(/^id,repo,type,date,status/); + }); +}); diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 7505a63c..6838b84a 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -30,6 +30,7 @@ pub mod payment_scheduler; pub mod quadratic_voting; pub mod rarity_validator; pub mod rbac; +pub mod referral_program; pub mod reputation_system; pub mod revocation; pub mod route_optimizer; diff --git a/contracts/src/referral_program.rs b/contracts/src/referral_program.rs new file mode 100644 index 00000000..8edd0dff --- /dev/null +++ b/contracts/src/referral_program.rs @@ -0,0 +1,103 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Referrer(Address), + ReferralCount(Address), + Rewarded(Address, Address), +} + +#[contract] +pub struct ReferralProgramContract; + +#[contractimpl] +impl ReferralProgramContract { + /// Register a referral relationship. Panics on self-referral or duplicate. + pub fn register_referral(env: Env, referrer: Address, referee: Address) { + if referrer == referee { + panic!("self-referral not allowed"); + } + let key = DataKey::Referrer(referee.clone()); + if env.storage().instance().has(&key) { + panic!("referee already has a referrer"); + } + env.storage().instance().set(&key, &referrer.clone()); + + let count_key = DataKey::ReferralCount(referrer.clone()); + let count: u32 = env.storage().instance().get(&count_key).unwrap_or(0); + env.storage().instance().set(&count_key, &(count + 1)); + } + + /// Returns the number of successful referrals made by `referrer`. + pub fn get_referral_count(env: Env, referrer: Address) -> u32 { + env.storage() + .instance() + .get(&DataKey::ReferralCount(referrer)) + .unwrap_or(0) + } + + /// Returns the referrer of `referee`, if any. + pub fn get_referrer(env: Env, referee: Address) -> Option
{ + env.storage() + .instance() + .get(&DataKey::Referrer(referee)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + fn setup() -> (Env, ReferralProgramContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(ReferralProgramContract, ()); + let client = ReferralProgramContractClient::new(&env, &id); + (env, client) + } + + #[test] + fn register_referral_stores_data() { + let (env, client) = setup(); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + + client.register_referral(&referrer, &referee); + + assert_eq!(client.get_referrer(&referee), Some(referrer)); + } + + #[test] + fn get_referral_count_increments() { + let (env, client) = setup(); + let referrer = Address::generate(&env); + let referee1 = Address::generate(&env); + let referee2 = Address::generate(&env); + + assert_eq!(client.get_referral_count(&referrer), 0); + client.register_referral(&referrer, &referee1); + assert_eq!(client.get_referral_count(&referrer), 1); + client.register_referral(&referrer, &referee2); + assert_eq!(client.get_referral_count(&referrer), 2); + } + + #[test] + #[should_panic] + fn self_referral_panics() { + let (env, client) = setup(); + let user = Address::generate(&env); + client.register_referral(&user, &user); + } + + #[test] + #[should_panic] + fn duplicate_referral_panics() { + let (env, client) = setup(); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + client.register_referral(&referrer, &referee); + client.register_referral(&referrer, &referee); + } +} diff --git a/frontend/src/app/playground/page.tsx b/frontend/src/app/playground/page.tsx index 712ec10e..36dd7064 100644 --- a/frontend/src/app/playground/page.tsx +++ b/frontend/src/app/playground/page.tsx @@ -9,6 +9,7 @@ import { TerminalPanel } from '@/components/terminal/TerminalPanel'; import { DatabaseManager } from '@/lib/storage/DatabaseManager'; import { SyncManager } from '@/lib/storage/SyncManager'; import { useState, useEffect, useMemo } from 'react'; +import { ContractSearch, type Contract } from '@/components/playground/ContractSearch'; import { WithSkeleton } from '@/components/ui/WithSkeleton'; import { EditorSkeleton } from '@/components/ui/skeletons/EditorSkeleton'; @@ -82,6 +83,7 @@ function moveFileNode( export default function PlaygroundPage() { const [output, setOutput] = useState(''); + const [selectedContract, setSelectedContract] = useState(null); const [isCompiling, setIsCompiling] = useState(false); const [isInitializing, setIsInitializing] = useState(true); const [treeData, setTreeData] = useState(INITIAL_TREE); @@ -276,7 +278,7 @@ export default function PlaygroundPage() { Execution_Output
-                {output || '> Initializing environment...\n> Awaiting input signal...'}
+                {output || '> Initializing environment...\n> Awaiting input signal...'}{selectedContract ? `\n> Loaded contract: ${selectedContract.name}` : ''}
               
{isCompiling && (
@@ -298,6 +300,7 @@ export default function PlaygroundPage() {

Laboratory Notes

+

This playground provides a{' '} real-time transpilation environment for Soroban diff --git a/frontend/src/app/roadmap/page.tsx b/frontend/src/app/roadmap/page.tsx index 55c17438..cbbdf829 100644 --- a/frontend/src/app/roadmap/page.tsx +++ b/frontend/src/app/roadmap/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import ShareButton from '@/components/roadmap/ShareButton'; const NODES = [ { @@ -184,6 +185,7 @@ export default function RoadmapPage() { ? 'Initiate Node' : 'Node Locked'} +

diff --git a/frontend/src/components/playground/ContractSearch.test.tsx b/frontend/src/components/playground/ContractSearch.test.tsx new file mode 100644 index 00000000..70bea774 --- /dev/null +++ b/frontend/src/components/playground/ContractSearch.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ContractSearch, SAMPLE_CONTRACTS } from './ContractSearch'; + +const onSelect = jest.fn(); + +describe('ContractSearch', () => { + beforeEach(() => { + render(); + }); + + it('renders search input and type filter', () => { + expect(screen.getByLabelText('Search contracts')).toBeInTheDocument(); + expect(screen.getByLabelText('Filter by type')).toBeInTheDocument(); + }); + + it('filtering by text reduces results', () => { + const input = screen.getByLabelText('Search contracts'); + fireEvent.change(input, { target: { value: 'Token' } }); + const results = SAMPLE_CONTRACTS.filter((c) => + c.name.toLowerCase().includes('token') + ); + expect(screen.getAllByRole('button').length).toBe(results.length); + }); + + it('filtering by type reduces results', () => { + const select = screen.getByLabelText('Filter by type'); + fireEvent.change(select, { target: { value: 'DeFi' } }); + const results = SAMPLE_CONTRACTS.filter((c) => c.type === 'DeFi'); + expect(screen.getAllByRole('button').length).toBe(results.length); + }); +}); diff --git a/frontend/src/components/playground/ContractSearch.tsx b/frontend/src/components/playground/ContractSearch.tsx new file mode 100644 index 00000000..30aae965 --- /dev/null +++ b/frontend/src/components/playground/ContractSearch.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useState } from 'react'; + +export type ContractType = 'All' | 'Token' | 'DeFi' | 'Governance' | 'NFT'; + +export interface Contract { + id: string; + name: string; + type: Exclude; + description: string; +} + +export const SAMPLE_CONTRACTS: Contract[] = [ + { id: '1', name: 'SimpleToken', type: 'Token', description: 'Basic fungible token contract' }, + { id: '2', name: 'LiquidityPool', type: 'DeFi', description: 'AMM liquidity pool with swap support' }, + { id: '3', name: 'DAOVoting', type: 'Governance', description: 'On-chain proposal voting system' }, + { id: '4', name: 'WrappedAsset', type: 'Token', description: 'Wrapped cross-chain asset contract' }, + { id: '5', name: 'StellarNFT', type: 'NFT', description: 'Non-fungible token with metadata' }, + { id: '6', name: 'YieldFarm', type: 'DeFi', description: 'Staking rewards distribution contract' }, + { id: '7', name: 'Multisig', type: 'Governance', description: 'Multi-signature approval contract' }, +]; + +const TYPES: ContractType[] = ['All', 'Token', 'DeFi', 'Governance', 'NFT']; + +interface ContractSearchProps { + onSelect: (contract: Contract) => void; +} + +export function ContractSearch({ onSelect }: ContractSearchProps) { + const [search, setSearch] = useState(''); + const [type, setType] = useState('All'); + + const filtered = SAMPLE_CONTRACTS.filter( + (c) => + (type === 'All' || c.type === type) && + c.name.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+

+ Contract Search +

+
+ setSearch(e.target.value)} + aria-label="Search contracts" + className="flex-1 rounded-lg border border-white/10 bg-zinc-950 px-3 py-2 text-[11px] text-white placeholder-gray-600 outline-none focus:border-red-500/50" + /> + +
+
    + {filtered.length === 0 ? ( +
  • No contracts found.
  • + ) : ( + filtered.map((c) => ( +
  • + +
  • + )) + )} +
+
+ ); +} diff --git a/frontend/src/components/roadmap/ShareButton.test.tsx b/frontend/src/components/roadmap/ShareButton.test.tsx new file mode 100644 index 00000000..dbac33f5 --- /dev/null +++ b/frontend/src/components/roadmap/ShareButton.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import ShareButton from './ShareButton'; + +const props = { title: 'Foundations', description: 'Ledger basics, accounts, and trustlines.' }; + +beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { href: 'http://localhost/roadmap' }, + writable: true, + }); + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + window.open = jest.fn(); +}); + +test('renders X, in, and Copy buttons', () => { + render(); + expect(screen.getByText('X')).toBeInTheDocument(); + expect(screen.getByText('in')).toBeInTheDocument(); + expect(screen.getByText('Copy')).toBeInTheDocument(); +}); + +test('calls window.open with Twitter URL on X click', () => { + render(); + fireEvent.click(screen.getByText('X')); + expect(window.open).toHaveBeenCalledWith( + expect.stringContaining('twitter.com/intent/tweet'), + '_blank', + 'noopener,noreferrer', + ); +}); + +test('calls clipboard.writeText with current URL on Copy click', async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText('Copy')); + }); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('http://localhost/roadmap'); +}); + +test('shows Copied! after copy and reverts after 1.5s', async () => { + jest.useFakeTimers(); + render(); + await act(async () => { + fireEvent.click(screen.getByText('Copy')); + }); + expect(screen.getByText('Copied!')).toBeInTheDocument(); + act(() => jest.advanceTimersByTime(1500)); + expect(screen.getByText('Copy')).toBeInTheDocument(); + jest.useRealTimers(); +}); diff --git a/frontend/src/components/roadmap/ShareButton.tsx b/frontend/src/components/roadmap/ShareButton.tsx new file mode 100644 index 00000000..260ca03f --- /dev/null +++ b/frontend/src/components/roadmap/ShareButton.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState } from 'react'; + +interface ShareButtonProps { + title: string; + description: string; +} + +export default function ShareButton({ title, description }: ShareButtonProps) { + const [copied, setCopied] = useState(false); + + const text = encodeURIComponent(`${title} — ${description}`); + const url = typeof window !== 'undefined' ? encodeURIComponent(window.location.href) : ''; + + const twitterUrl = `https://twitter.com/intent/tweet?text=${text}`; + const linkedInUrl = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${encodeURIComponent(title)}`; + + function handleCopy() { + navigator.clipboard.writeText(window.location.href).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + } + + const btnClass = + 'flex-1 rounded border border-white/10 bg-zinc-950 py-1.5 text-xs font-mono tracking-widest text-gray-400 transition-colors hover:border-white/20 hover:text-white uppercase'; + + return ( + + ); +}