Skip to content
Merged
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
56 changes: 25 additions & 31 deletions .github/workflows/pr-target-check.yml
Original file line number Diff line number Diff line change
@@ -1,35 +1,29 @@
name: PR Target Check
name: Redirect PRs to dev

on:
pull_request_target:
branches:
- main
pull_request_target:
branches:
- main

jobs:
warn-wrong-target:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment and close PR targeting main
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `👋 Hey @${context.payload.pull_request.user.login}, thanks for your contribution!\n\n` +
`This PR is targeting \`main\` directly. We use \`main\` for stable releases only — ` +
`all contributions should be opened against the \`dev\` branch instead.\n\n` +
`**Please close this PR and reopen it with \`dev\` as the base branch.** ` +
`If you're unsure how to do that, you can change the base branch using the *Edit* button at the top of this PR page.\n\n` +
`Closing this PR automatically. See you in \`dev\`! 🚀`
});

await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed'
});
close-and-redirect:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Close PR and redirect to dev
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: "👋 Thanks for your contribution! We don't accept pull requests directly to `main`. Please re-open your PR targeting the `dev` branch instead. See our contributing guide for details."
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed'
});
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ dist-ssr
issue.md
pr.md

# MiMoCode
.mimocode/
# Test artifacts
__snapshots__
*.snap
Expand All @@ -39,4 +41,4 @@ Thumbs.db

# Lock files from other package managers
package-lock.json
yarn.lock
yarn.lock
136 changes: 136 additions & 0 deletions src/components/common/AirdropPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import showToast from '@/utils/toast.util';
import {
parseAirdropInput,
validateAirdropRecipients,
type AirdropRecipient,
} from '@/utils/airdropValidation';

const MAX_RECIPIENTS = 50;

const AirdropPanel: React.FC = () => {
const [rawInput, setRawInput] = useState('');
const [submitted, setSubmitted] = useState(false);

const recipients: AirdropRecipient[] = useMemo(
() => parseAirdropInput(rawInput),
[rawInput]
);

const errors = useMemo(
() => validateAirdropRecipients(recipients),
[recipients]
);

const hasLimitError = errors.some(e => e.rowIndex === -1);
const rowErrors = errors.filter(e => e.rowIndex >= 0);
const rowErrorMap = new Map(rowErrors.map(e => [e.rowIndex, e.message]));
const totalKeys = recipients.reduce((sum, r) => sum + r.quantity, 0);
const canSubmit =
recipients.length > 0 &&
recipients.length <= MAX_RECIPIENTS &&
rowErrors.length === 0;

const handleSubmit = () => {
if (!canSubmit) return;
setSubmitted(true);
showToast.transactionSuccess(
'Airdrop submitted',
`${totalKeys} keys distributed to ${recipients.length} recipients.`
);
};

const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setRawInput(String(reader.result ?? ''));
};
reader.readAsText(file);
};

return (
<div className="space-y-4">
<h3 className="font-grotesque text-xl font-black text-white">
Airdrop Keys
</h3>
<p className="text-sm text-white/60">
Paste wallet:quantity pairs (one per line) or upload a CSV. Up to{' '}
{MAX_RECIPIENTS} recipients.
</p>

<textarea
value={rawInput}
onChange={e => {
setRawInput(e.target.value);
setSubmitted(false);
}}
placeholder={'GABC...:5\nGDEF...:10'}
rows={6}
className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 font-mono text-sm text-white outline-none focus:border-amber-500/50"
aria-label="Airdrop recipient list"
/>

<label className="inline-block cursor-pointer text-xs font-semibold text-amber-400 underline">
Upload CSV
<input
type="file"
accept=".csv,.txt"
onChange={handleFileUpload}
className="hidden"
/>
</label>

{hasLimitError && (
<p role="alert" className="text-sm font-bold text-red-400">
Maximum 50 recipients per airdrop
</p>
)}

{recipients.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-white/10 text-xs text-white/50">
<th className="pb-2 pr-4">#</th>
<th className="pb-2 pr-4">Address</th>
<th className="pb-2 pr-4">Qty</th>
<th className="pb-2">Error</th>
</tr>
</thead>
<tbody>
{recipients.map((r, i) => (
<tr key={i} className="border-b border-white/5">
<td className="py-2 pr-4 text-white/40">{i + 1}</td>
<td className="py-2 pr-4 font-mono text-white/80 truncate max-w-[200px]">
{r.address}
</td>
<td className="py-2 pr-4 text-white/80">{r.quantity}</td>
<td className="py-2 text-red-400 text-xs">
{rowErrorMap.get(i) ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3 text-sm font-bold text-white/70">
Total: {totalKeys} keys → {recipients.length} recipients
</div>
</div>
)}

<Button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="rounded-xl"
>
{submitted ? 'Submitted' : 'Submit Airdrop'}
</Button>
</div>
);
};

export default AirdropPanel;
42 changes: 42 additions & 0 deletions src/components/common/AuctionPhaseBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
interface AuctionPhaseBannerProps {
auctionPrice: number;
auctionSupply: number;
auctionSold: number;
}

const AuctionPhaseBanner: React.FC<AuctionPhaseBannerProps> = ({
auctionPrice,
auctionSupply,
auctionSold,
}) => {
const remaining = auctionSupply - auctionSold;
const progressPercent = Math.min(100, (auctionSold / auctionSupply) * 100);

if (remaining <= 0) return null;

return (
<div
role="status"
aria-live="polite"
className="rounded-2xl border border-amber-500/30 bg-amber-500/10 p-5"
>
<p className="text-xs font-bold uppercase tracking-[0.2em] text-amber-400">
Auction Phase
</p>
<p className="mt-1 font-grotesque text-lg font-black text-white">
Early access price: {auctionPrice} XLM — {remaining} keys left
</p>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-amber-400 transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="mt-1 text-xs text-white/50">
{auctionSold} / {auctionSupply} sold
</p>
</div>
);
};

export default AuctionPhaseBanner;
34 changes: 34 additions & 0 deletions src/components/common/GlobalPauseBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { AlertTriangle } from 'lucide-react';

interface GlobalPauseBannerProps {
pauseActivatedAt?: string;
}

const GlobalPauseBanner: React.FC<GlobalPauseBannerProps> = ({
pauseActivatedAt,
}) => {
const formatted = pauseActivatedAt
? new Date(pauseActivatedAt).toLocaleString()
: null;

return (
<div
role="alert"
aria-live="assertive"
className="fixed inset-x-0 top-0 z-[100] flex items-center justify-center gap-3 bg-red-600 px-4 py-3 text-center text-sm font-bold text-white shadow-lg"
>
<AlertTriangle className="size-5 shrink-0" aria-hidden="true" />
<span>
Trading is temporarily suspended across all keys. We are working to
resolve this.
{formatted && (
<span className="ml-2 text-xs font-normal text-white/80">
Paused since {formatted}
</span>
)}
</span>
</div>
);
};

export default GlobalPauseBanner;
41 changes: 41 additions & 0 deletions src/hooks/useGlobalPause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';

export interface ProtocolStatus {
globalTradingPaused: boolean;
pauseActivatedAt?: string;
}

const POLL_INTERVAL_MS = 60_000;

export function useGlobalPause() {
const [paused, setPaused] = useState(false);
const [pauseActivatedAt, setPauseActivatedAt] = useState<
string | undefined
>();

useEffect(() => {
let active = true;

const check = async () => {
try {
const res = await fetch('/protocol/status');
if (!res.ok) return;
const data: ProtocolStatus = await res.json();
if (!active) return;
setPaused(data.globalTradingPaused);
setPauseActivatedAt(data.pauseActivatedAt);
} catch {
// silently ignore — will retry next poll
}
};

check();
const id = window.setInterval(check, POLL_INTERVAL_MS);
return () => {
active = false;
window.clearInterval(id);
};
}, []);

return { paused, pauseActivatedAt };
}
Loading
Loading