diff --git a/.github/workflows/verify-contract.yml b/.github/workflows/verify-contract.yml new file mode 100644 index 0000000..6300599 --- /dev/null +++ b/.github/workflows/verify-contract.yml @@ -0,0 +1,115 @@ +name: Verify Contract on StellarExpert + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + contract_id: + description: 'Deployed contract ID to verify' + required: true + type: string + network: + description: 'Network (testnet or mainnet)' + required: true + default: 'testnet' + type: choice + options: + - testnet + - mainnet + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Build & Verify Contract + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: rustfmt + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + contracts/escrow/target/ + key: ${{ runner.os }}-cargo-verify-${{ hashFiles('contracts/escrow/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-verify- + ${{ runner.os }}-cargo- + + - name: Install soroban-cli + run: | + if ! command -v soroban &> /dev/null; then + cargo install soroban-cli --locked + fi + + - name: Build contract WASM + working-directory: contracts/escrow/ + run: soroban contract build + + - name: Compute WASM hash + id: wasm_hash + run: | + WASM_FILE="contracts/escrow/target/wasm32-unknown-unknown/release/escrow.wasm" + HASH=$(sha256sum "$WASM_FILE" | cut -d' ' -f1) + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + echo "WASM hash: $HASH" + + - name: Determine contract ID and network + id: contract_info + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "contract_id=${{ inputs.contract_id }}" >> "$GITHUB_OUTPUT" + echo "network=${{ inputs.network }}" >> "$GITHUB_OUTPUT" + else + CONTRACT_ID=$(node -e " + const fs = require('fs'); + const data = JSON.parse(fs.readFileSync('contract-addresses.json', 'utf8')); + const network = 'testnet'; + console.log(data[network]?.contractId || ''); + ") + echo "contract_id=$CONTRACT_ID" >> "$GITHUB_OUTPUT" + echo "network=testnet" >> "$GITHUB_OUTPUT" + fi + + - name: Verify contract on StellarExpert + if: steps.contract_info.outputs.contract_id != '' + run: | + chmod +x scripts/verify-contract.sh + ./scripts/verify-contract.sh \ + "${{ steps.contract_info.outputs.contract_id }}" \ + "${{ steps.contract_info.outputs.network }}" \ + "contracts/escrow/target/wasm32-unknown-unknown/release/escrow.wasm" + + - name: Upload WASM artifact + uses: actions/upload-artifact@v4 + with: + name: escrow-wasm-${{ steps.wasm_hash.outputs.hash }} + path: contracts/escrow/target/wasm32-unknown-unknown/release/escrow.wasm + retention-days: 30 + + - name: Verification summary + run: | + echo "## Contract Verification Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Contract ID | \`${{ steps.contract_info.outputs.contract_id }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Network | ${{ steps.contract_info.outputs.network }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| WASM Hash | \`${{ steps.wasm_hash.outputs.hash }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Git Ref | ${{ github.ref }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Commit | ${{ github.sha }} |" >> "$GITHUB_STEP_SUMMARY" diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index bb7d4a8..8bddd1c 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -254,12 +254,7 @@ pub enum Error { NegativeAmount = 28, DeadlineTooSoon = 29, PayloadTooLarge = 30, - RevisionLimitReached = 31, -} - -#[derive(Clone, Debug)] -#[contracttype] - pub submitted_at: u64, + RevisionLimitReached = 31, } #[contracttype] @@ -349,6 +344,14 @@ pub struct JobTemplate { pub token: Address, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PaymentSplit { + pub recipient: Address, + pub share_bps: u32, + pub reason: soroban_sdk::String, +} + #[contracttype] #[derive(Clone)] pub enum DataKey { @@ -408,6 +411,9 @@ pub enum DataKey { // ── Job templates (issue #446) ─────────────────────────────────────────── TemplateCount(Address), Template(Address, u64), + // ── Payment splits ─────────────────────────────────────────────────────── + PaymentSplitCount(u64), + PaymentSplit(u64, u32), } #[contracterror] @@ -1502,28 +1508,7 @@ impl EscrowContract { } if client_share_bps < 0 || client_share_bps > BPS_DENOMINATOR { panic_with_error!(&e, Error::InvalidAmount); - pub fn remove_from_whitelist(env: Env, admin: Address, addr: Address) { - admin.require_auth(); - require_admin(&env); - env.storage().persistent().remove(&DataKey::Whitelisted(addr)); - } - - pub fn is_whitelisted(env: Env, addr: Address) -> bool { - is_whitelisted(&env, &addr) - } - - pub fn is_whitelisted_public(env: Env, addr: Address) -> bool { - is_whitelisted(&env, &addr) - } - - pub fn add_to_blacklist(env: Env, admin: Address, addr: Address) { - admin.require_auth(); - require_admin(&env); - if env.storage().persistent().has(&DataKey::Blacklisted(addr.clone())) { - panic!("already blacklisted"); } - env.storage().persistent().set(&DataKey::Blacklisted(addr), &true); - } let client_share = checked_mul_div(&e, job.amount, client_share_bps, BPS_DENOMINATOR); let freelancer_share = checked_sub(&e, job.amount, client_share); @@ -1541,7 +1526,7 @@ impl EscrowContract { &e.current_contract_address(), &freelancer, &freelancer_share, - ); + ); } e.events().publish( @@ -1550,6 +1535,29 @@ impl EscrowContract { ); } + pub fn remove_from_whitelist(env: Env, admin: Address, addr: Address) { + admin.require_auth(); + require_admin(&env); + env.storage().persistent().remove(&DataKey::Whitelisted(addr)); + } + + pub fn is_whitelisted(env: Env, addr: Address) -> bool { + is_whitelisted(&env, &addr) + } + + pub fn is_whitelisted_public(env: Env, addr: Address) -> bool { + is_whitelisted(&env, &addr) + } + + pub fn add_to_blacklist(env: Env, admin: Address, addr: Address) { + admin.require_auth(); + require_admin(&env); + if env.storage().persistent().has(&DataKey::Blacklisted(addr.clone())) { + panic!("already blacklisted"); + } + env.storage().persistent().set(&DataKey::Blacklisted(addr), &true); + } + pub fn extend_job_ttl(e: Env, caller: Address, job_id: u64) { caller.require_auth(); require_active_access(&e, &caller); @@ -1595,6 +1603,19 @@ impl EscrowContract { } freelancer.require_auth(); require_active_access(&e, freelancer); + } + + let old_deadline = job.deadline; + job.deadline = new_deadline; + set_job(&e, job_id, &job); + bump_instance_ttl(&e); + + e.events().publish( + (Symbol::new(&e, "deadline_extended"),), + (job_id, client, old_deadline, new_deadline), + ); + } + pub fn remove_from_blacklist(env: Env, admin: Address, addr: Address) { admin.require_auth(); require_admin(&env); @@ -1653,15 +1674,49 @@ impl EscrowContract { } if total <= 0 { panic!("invalid amount"); } - let old_deadline = job.deadline; - job.deadline = new_deadline; - set_job(&e, job_id, &job); - bump_instance_ttl(&e); + let token = token::Client::new(&env, &token_address); + let balance = token.balance(&client); + if balance < total { panic!("insufficient balance"); } + token.transfer(&client, &env.current_contract_address(), &total); - e.events().publish( - (Symbol::new(&e, "deadline_extended"),), - (job_id, client, old_deadline, new_deadline), - ); + let mut count: u64 = env.storage().instance().get(&DataKey::JobCount).unwrap_or(0); + count += 1; + env.storage().instance().set(&DataKey::JobCount, &count); + + let milestone_count: u32 = milestones.len() as u32; + env.storage().persistent().set(&DataKey::MilestoneCount(count), &milestone_count); + for (i, m) in milestones.iter().enumerate() { + env.storage().persistent().set(&DataKey::Milestone(count, i as u32), &m); + } + + let job = Job { + client: client.clone(), + freelancer: None, + amount: total, + description_hash: BytesN::from_array(&env, &[0u8; 32]), + status: JobStatus::Open, + created_at: current_ledger(&env), + deadline, + token: token_address, + revision_count: 0, + submitted_at: 0, + title, + category, + version: INITIAL_JOB_VERSION, + }; + put_job(&env, count, &job); + + let mut c_jobs: Vec = env + .storage() + .persistent() + .get(&DataKey::ClientJobs(client.clone())) + .unwrap_or_else(|| Vec::new(&env)); + c_jobs.push_back(count); + env.storage() + .persistent() + .set(&DataKey::ClientJobs(client.clone()), &c_jobs); + + count } pub fn raise_dispute(e: Env, caller: Address, job_id: u64) { @@ -2262,6 +2317,8 @@ impl EscrowContract { .persistent() .get::(&DataKey::JobVisibility(job_id)) .unwrap_or(JobVisibility::Public) + } + pub fn approve_milestone(env: Env, client: Address, job_id: u64, milestone_id: u32) { client.require_auth(); let job = get_job(&env, job_id); @@ -3011,6 +3068,109 @@ impl EscrowContract { total_volume, } } + + pub fn approve_with_splits(e: Env, client: Address, job_id: u64, splits: Vec) { + client.require_auth(); + require_active_access(&e, &client); + + let mut job = get_job_or_panic(&e, job_id); + if job.client != client { + panic_with_error!(&e, Error::Unauthorized); + } + if job.status != JobStatus::SubmittedForReview { + panic_with_error!(&e, Error::InvalidStatus); + } + + let freelancer = match job.freelancer.clone() { + Option::Some(addr) => addr, + Option::None => panic_with_error!(&e, Error::InvalidStatus), + }; + + let mut total_bps: u32 = 0; + for i in 0..splits.len() { + let split = splits.get(i).unwrap(); + if split.share_bps > 5_000 { + panic_with_error!(&e, Error::InvalidAmount); + } + total_bps += split.share_bps; + } + if total_bps != BPS_DENOMINATOR as u32 { + panic_with_error!(&e, Error::InvalidAmount); + } + + let fee_bps = get_fee_bps_storage(&e); + let total_fee = checked_mul_div(&e, job.amount, fee_bps, BPS_DENOMINATOR); + let distributable = checked_sub(&e, job.amount, total_fee); + + let current_fees = get_token_fees(&e, &job.token); + let updated_fees = checked_add(&e, current_fees, total_fee); + e.storage().persistent().set(&DataKey::TokenFees(job.token.clone()), &updated_fees); + bump_token_fees_ttl(&e, &job.token); + + let token_client = token::Client::new(&e, &job.token); + for i in 0..splits.len() { + let split = splits.get(i).unwrap(); + let share_amount = checked_mul_div(&e, distributable, split.share_bps as i128, BPS_DENOMINATOR); + if share_amount > 0 { + token_client.transfer(&e.current_contract_address(), &split.recipient, &share_amount); + } + e.events().publish( + (Symbol::new(&e, "payment_split_released"),), + (job_id, split.recipient.clone(), share_amount, split.reason.clone()), + ); + } + + job.status = JobStatus::Completed; + set_job(&e, job_id, &job); + bump_instance_ttl(&e); + + e.events().publish( + (Symbol::new(&e, "job_approved"),), + (job_id, client.clone(), freelancer.clone(), distributable), + ); + } + + pub fn set_payment_splits(e: Env, client: Address, job_id: u64, splits: Vec) { + client.require_auth(); + let job = get_job_or_panic(&e, job_id); + if job.client != client { + panic_with_error!(&e, Error::Unauthorized); + } + if job.status != JobStatus::Open { + panic_with_error!(&e, Error::InvalidStatus); + } + + let mut total_bps: u32 = 0; + for i in 0..splits.len() { + let split = splits.get(i).unwrap(); + if split.share_bps > 5_000 { + panic_with_error!(&e, Error::InvalidAmount); + } + total_bps += split.share_bps; + } + if total_bps != BPS_DENOMINATOR as u32 { + panic_with_error!(&e, Error::InvalidAmount); + } + + let count = splits.len(); + e.storage().persistent().set(&DataKey::PaymentSplitCount(job_id), &(count as u32)); + for i in 0..count { + let split = splits.get(i as u32).unwrap(); + e.storage().persistent().set(&DataKey::PaymentSplit(job_id, i as u32), &split); + } + bump_instance_ttl(&e); + } + + pub fn get_available_splits(e: Env, job_id: u64) -> Vec { + let count: u32 = e.storage().persistent().get(&DataKey::PaymentSplitCount(job_id)).unwrap_or(0); + let mut result: Vec = Vec::new(&e); + for i in 0..count { + if let Some(split) = e.storage().persistent().get::(&DataKey::PaymentSplit(job_id, i)) { + result.push_back(split); + } + } + result + } } /// Core dispute resolution logic shared by `resolve_dispute` and `batch_resolve_disputes`. @@ -9338,7 +9498,7 @@ mod test { } env.storage().instance().remove(&key); } -} + // ── Issue #412: Referral reward system tests ────────────────────────────── #[test] @@ -10069,5 +10229,121 @@ mod test { let (_, client, _, user, _, _) = setup(); client.get_dashboard_stats(&user); } + + #[test] + fn approve_with_splits_distributes_correctly() { + let (env, client, _, user, freelancer, native_token) = setup(); + let job_id = client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + client.accept_job(&freelancer, &job_id); + client.submit_work(&freelancer, &job_id); + + let referrer = Address::generate(&env); + let splits = soroban_sdk::vec![ + &env, + PaymentSplit { + recipient: freelancer.clone(), + share_bps: 7_000, + reason: soroban_sdk::String::from_str(&env, "freelancer"), + }, + PaymentSplit { + recipient: referrer.clone(), + share_bps: 3_000, + reason: soroban_sdk::String::from_str(&env, "referrer"), + }, + ]; + + let token_client = token::Client::new(&env, &native_token); + let freelancer_pre = token_client.balance(&freelancer); + let referrer_pre = token_client.balance(&referrer); + + client.approve_with_splits(&user, &job_id, &splits); + + let fee = 1_000_000 * DEFAULT_FEE_BPS / BPS_DENOMINATOR; + let distributable = 1_000_000 - fee; + let freelancer_share = distributable * 7_000 / 10_000; + let referrer_share = distributable * 3_000 / 10_000; + + assert_eq!(token_client.balance(&freelancer) - freelancer_pre, freelancer_share); + assert_eq!(token_client.balance(&referrer) - referrer_pre, referrer_share); + assert_eq!(client.get_job(&job_id).status, JobStatus::Completed); + } + + #[test] + #[should_panic(expected = "Error(Contract, #11)")] + fn approve_with_splits_rejects_invalid_total() { + let (env, client, _, user, freelancer, native_token) = setup(); + let job_id = client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + client.accept_job(&freelancer, &job_id); + client.submit_work(&freelancer, &job_id); + + let splits = soroban_sdk::vec![ + &env, + PaymentSplit { + recipient: freelancer.clone(), + share_bps: 5_000, + reason: soroban_sdk::String::from_str(&env, "partial"), + }, + ]; + client.approve_with_splits(&user, &job_id, &splits); + } + + #[test] + #[should_panic(expected = "Error(Contract, #11)")] + fn approve_with_splits_rejects_single_over_max() { + let (env, client, _, user, freelancer, native_token) = setup(); + let job_id = client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + client.accept_job(&freelancer, &job_id); + client.submit_work(&freelancer, &job_id); + + let other = Address::generate(&env); + let splits = soroban_sdk::vec![ + &env, + PaymentSplit { + recipient: freelancer.clone(), + share_bps: 6_000, + reason: soroban_sdk::String::from_str(&env, "too_much"), + }, + PaymentSplit { + recipient: other, + share_bps: 4_000, + reason: soroban_sdk::String::from_str(&env, "other"), + }, + ]; + client.approve_with_splits(&user, &job_id, &splits); + } + + #[test] + fn set_and_get_payment_splits() { + let (env, client, _, user, _, native_token) = setup(); + let job_id = client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + + let referrer = Address::generate(&env); + let splits = soroban_sdk::vec![ + &env, + PaymentSplit { + recipient: user.clone(), + share_bps: 7_000, + reason: soroban_sdk::String::from_str(&env, "freelancer"), + }, + PaymentSplit { + recipient: referrer, + share_bps: 3_000, + reason: soroban_sdk::String::from_str(&env, "referrer"), + }, + ]; + + client.set_payment_splits(&user, &job_id, &splits); + let stored = client.get_available_splits(&job_id); + assert_eq!(stored.len(), 2); + assert_eq!(stored.get(0).unwrap().share_bps, 7_000); + assert_eq!(stored.get(1).unwrap().share_bps, 3_000); + } + + #[test] + fn get_available_splits_returns_empty_when_none() { + let (env, client, _, user, _, native_token) = setup(); + let job_id = client.post_job(&user, &1_000_000i128, &hash(&env), &32u32, &0u64, &native_token); + let stored = client.get_available_splits(&job_id); + assert_eq!(stored.len(), 0); + } } -mod test; diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index a34256a..9063ebe 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -25,6 +25,7 @@ import StatusPill from "@/components/StatusPill"; import TruncatedAddress from "@/components/TruncatedAddress"; import { useToast } from "@/components/ToastProvider"; import RecentContractInteractionsWidget from "@/app/dashboard/RecentContractInteractionsWidget"; +import DashboardWidgets from "@/components/DashboardWidgets"; import { useNotifications, getEventLabel } from "@/lib/notifications-context"; import { formatDeadline, toXlm } from "@/lib/format"; import { isConfirmSuppressed, CONFIRM_KEYS } from "@/lib/confirm-prefs"; @@ -436,6 +437,7 @@ export default function DashboardPage() { } return ( +
@@ -586,7 +588,6 @@ export default function DashboardPage() { batchLoading={batchLoading} selectedJobIds={selectedJobIds} onToggleSelectBulk={handleToggleSelect} - onToggleBulkCancelSelect={handleToggleSelect} onSelectAll={handleSelectAllOpen} onDeselectAll={handleDeselectAll} onBulkCancel={() => setShowBulkConfirm(true)} @@ -773,6 +774,7 @@ export default function DashboardPage() { ); })()}
+
); } @@ -794,7 +796,6 @@ function JobSection({ batchLoading, selectedJobIds = new Set(), onToggleSelectBulk, - onToggleBulkCancelSelect, onSelectAll, onDeselectAll, onBulkCancel, @@ -818,7 +819,6 @@ function JobSection({ selectedJobIds?: Set; onToggleSelectBulk?: (id: number) => void; onToggleSelect?: (id: number) => void; - onToggleBulkCancelSelect?: (id: number) => void; onSelectAll?: () => void; onDeselectAll?: () => void; onBulkCancel?: () => void; @@ -833,10 +833,6 @@ function JobSection({ const allOpenSelected = openClientJobs.length > 0 && openClientJobs.every((j) => selectedJobIds.has(j.id)); - const openClientJobs = role === "client" ? jobs.filter((j) => j.job.status === "Open") : []; - const selectionCount = openClientJobs.filter((j) => selectedJobIds.has(j.id)).length; - const allOpenSelected = openClientJobs.length > 0 && openClientJobs.every((j) => selectedJobIds.has(j.id)); - return (
@@ -844,44 +840,12 @@ function JobSection({

{title}

{subtitle}

-
- {/* Batch approve controls — client only, pending review jobs */} - {role === "client" && hasPendingReview && ( - <> - - {selectedJobs?.size ?? 0} of {pendingReviewIds.length} selected -
{role === "client" && hasPendingReview && (
{selectedJobs?.size ?? 0} of {pendingReviewIds.length} selected - {role === "client" && hasPendingReview && ( -
- - {selectedJobs?.size ?? 0} of {pendingReviewIds.length} selected - - -
- )} - {/* Bulk cancel controls — client only, at least 1 open job */} - {role === "client" && openClientJobs.length > 0 && ( -
- - {selectionCount >= 2 && ( - - )} - {/* Bulk cancel controls — client only, at least 1 open job */} - {role === "client" && openClientJobs.length > 0 && ( - <> - {batchLoading - ? "Approving..." - : `Approve Selected (${selectedJobs?.size ?? 0})`} -
)} {role === "client" && openClientJobs.length > 0 && ( @@ -918,11 +873,6 @@ function JobSection({ onClick={onBulkCancel} > {bulkCancelProgress - ? `Cancelling… ${bulkCancelProgress.done}/${bulkCancelProgress.total}` - : `Cancel selected (${selectionCount})`} - - )} - ? `Cancelling... ${bulkCancelProgress.done}/${bulkCancelProgress.total}` : `Cancel selected (${selectionCount})`} @@ -956,7 +906,7 @@ function JobSection({ : false; const toggle = canBulkCancel - ? onToggleSelect + ? onToggleSelectBulk : canBatchApprove ? onToggleBatchSelect : undefined; @@ -978,31 +928,6 @@ function JobSection({ ); })} - {jobs.map(({ id, job }) => ( -
  • - -
  • - ))} )}
    @@ -1018,12 +943,8 @@ function JobCard({ onAction, onRequestAction, isSelected = false, - selectionTone = "red", onToggleSelect, - onToggleSelectBulk, selectMode, - isBulkCancelSelected = false, - onToggleBulkCancelSelect, }: { id: number; job: Job; @@ -1033,9 +954,7 @@ function JobCard({ onAction: (fn: () => Promise, jobId: number, notification?: { event: NotificationEvent; message: string }) => Promise; onRequestAction: (type: PendingDashAction["type"], jobId: number, amountXlm: string) => void; isSelected?: boolean; - selectionTone?: "emerald" | "red"; onToggleSelect?: (id: number) => void; - onToggleSelectBulk?: (id: number) => void; selectMode?: "cancel" | "approve"; }) { const actions = getActions(id, job, wallet, role); @@ -1048,32 +967,12 @@ function JobCard({ : ""; return ( -
    {onToggleSelect && ( onToggleSelect(id)} - className="h-4 w-4 rounded border-slate-300 text-emerald-600" - aria-label={`Select Job #${id} for batch approval`} -
    -
    - {onToggleSelect && ( - /> - )} - {onToggleSelectBulk && ( aria-label={ selectMode === "approve" ? `Select Job #${id} for batch approval` @@ -1084,39 +983,6 @@ function JobCard({ className={`h-4 w-4 rounded border-slate-300 cursor-pointer ${ selectMode === "approve" ? "text-emerald-600" : "accent-red-600" }`} - isBulkCancelSelected?: boolean; - onToggleBulkCancelSelect?: (id: number) => void; -}) { - const actions = getActions(id, job, wallet, role); - const amountXlm = `${toXlm(job.amount)} XLM`; - const selectionRing = isSelected - ? "ring-2 ring-emerald-400" - : isBulkCancelSelected - ? "ring-2 ring-red-400" - : ""; - - return ( -
    -
    -
    - {onToggleSelect && job.status === "SubmittedForReview" && ( - onToggleSelectBulk(id)} - className="h-4 w-4 cursor-pointer rounded border-slate-300 accent-red-600" - onChange={() => onToggleSelect(id)} - className="h-4 w-4 rounded border-slate-300 text-emerald-600" - aria-label={`Select Job #${id} for batch approval`} - /> - )} - {onToggleBulkCancelSelect && ( - onToggleBulkCancelSelect(id)} - className="h-4 w-4 rounded border-slate-300 accent-red-600 cursor-pointer" /> )}
    diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 26849e1..dd1fb92 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -4,7 +4,6 @@ import Link from "next/link"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; import dynamic from "next/dynamic"; -import Link from "next/link"; import { WalletProvider } from "@/lib/wallet-context"; import { ToastProvider } from "@/components/ToastProvider"; import { NotificationProvider } from "@/lib/notifications-context"; @@ -29,6 +28,7 @@ const InstallPrompt = dynamic(() => import("@/components/InstallPrompt"), { ssr: const ServiceWorkerRegistration = dynamic(() => import("@/components/ServiceWorkerRegistration"), { ssr: false }); const AnnouncementBanner = dynamic(() => import("@/components/AnnouncementBanner"), { ssr: false }); const MetricsReporter = dynamic(() => import("@/components/MetricsReporter"), { ssr: false }); +const Sidebar = dynamic(() => import("@/components/Sidebar"), { ssr: false }); const geistSans = Geist({ variable: "--font-geist-sans", @@ -160,9 +160,10 @@ export default async function RootLayout({ + -
    +
    {children}