Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions backend/src/routes/contribution-export.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
38 changes: 38 additions & 0 deletions backend/src/services/contributionExportService.ts
Original file line number Diff line number Diff line change
@@ -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');
}
53 changes: 53 additions & 0 deletions backend/tests/contributionExport.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
103 changes: 103 additions & 0 deletions contracts/src/referral_program.rs
Original file line number Diff line number Diff line change
@@ -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<Address> {
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);
}
}
1 change: 1 addition & 0 deletions frontend/src/app/playground/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ export default function PlaygroundPage() {
<h4 className="mb-4 text-[10px] font-black tracking-widest text-white uppercase">
Laboratory Notes
</h4>
<ContractSearch onSelect={setSelectedContract} />
<p className="text-[11px] leading-relaxed font-light text-gray-500">
This playground now includes the educational notarization and payment gateway modules.
Learners can inspect hash timestamping, escrowed payment processing, refunds, and
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/roadmap/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export default function RoadmapPage() {
>
Retry Connection
</button>
<ShareButton title={activeNode.title} description={activeNode.desc} />
</div>
) : (
<>
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/components/playground/ContractSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ContractSearch onSelect={onSelect} />);
});

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);
});
});
85 changes: 85 additions & 0 deletions frontend/src/components/playground/ContractSearch.tsx
Original file line number Diff line number Diff line change
@@ -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<ContractType, 'All'>;
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<ContractType>('All');

const filtered = SAMPLE_CONTRACTS.filter(
(c) =>
(type === 'All' || c.type === type) &&
c.name.toLowerCase().includes(search.toLowerCase())
);

return (
<div className="mb-6 font-mono">
<p className="mb-3 text-[10px] font-black tracking-widest text-white uppercase">
Contract Search
</p>
<div className="flex gap-2 mb-3">
<input
type="text"
placeholder="Filter by name..."
value={search}
onChange={(e) => 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"
/>
<select
value={type}
onChange={(e) => setType(e.target.value as ContractType)}
aria-label="Filter by type"
className="rounded-lg border border-white/10 bg-zinc-950 px-3 py-2 text-[11px] text-white outline-none focus:border-red-500/50"
>
{TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<ul className="max-h-48 overflow-y-auto rounded-lg border border-white/10">
{filtered.length === 0 ? (
<li className="px-3 py-2 text-[11px] text-gray-600">No contracts found.</li>
) : (
filtered.map((c) => (
<li key={c.id}>
<button
onClick={() => onSelect(c)}
className="w-full px-3 py-2 text-left text-[11px] hover:bg-white/5 border-b border-white/5 last:border-0"
>
<span className="text-white">{c.name}</span>
<span className="ml-2 text-[9px] text-red-500 uppercase">{c.type}</span>
<p className="text-gray-500 mt-0.5">{c.description}</p>
</button>
</li>
))
)}
</ul>
</div>
);
}
Loading
Loading