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
6 changes: 6 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import Send from '@/pages/Send';
import Receive from '@/pages/Receive';
import Privacy from '@/pages/Privacy';
import { HelpButton } from '@/components/HelpButton';
import { UndoToast } from '@/components/UndoToast';
import Contacts from '@/pages/Contacts';
import Notifications from '@/pages/Notifications';
import Vault from '@/pages/Vault';
import Schedule from '@/pages/Schedule';

Expand All @@ -23,10 +26,13 @@ export function App() {
<Route path="/vault" element={<Vault />} />
<Route path="/schedule" element={<Schedule />} />
<Route path="/pay" element={<Send />} />
<Route path="/contacts" element={<Contacts />} />
<Route path="/notifications" element={<Notifications />} />
<Route path="*" element={<Navigate to="/send" replace />} />
</Routes>
</main>
<HelpButton />
<UndoToast />
</div>
);
}
65 changes: 65 additions & 0 deletions src/components/UndoToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { useUndoStore } from '@/stores/undoStore';

function ToastItem({
id,
message,
onUndo,
expiresAt,
}: {
id: string;
message: string;
onUndo: () => void;
expiresAt: number;
}) {
const removeToast = useUndoStore((s) => s.removeToast);
const [remaining, setRemaining] = useState(5);

useEffect(() => {
const interval = setInterval(() => {
const secs = Math.ceil((expiresAt - Date.now()) / 1000);
setRemaining(secs > 0 ? secs : 0);
}, 200);
return () => clearInterval(interval);
}, [expiresAt]);

const handleUndo = () => {
onUndo();
removeToast(id);
};

return (
<div className="flex items-center gap-3 rounded-xl bg-surface-variant px-4 py-3 shadow-lg">
<span className="font-body text-sm text-on-surface-variant flex-1">{message}</span>
<button
onClick={handleUndo}
className="font-body text-sm font-semibold text-primary hover:underline"
>
Undo
</button>
<span className="font-mono text-xs text-on-surface-variant opacity-60 w-4 text-right">
{remaining}s
</span>
<button
onClick={() => removeToast(id)}
className="text-on-surface-variant opacity-60 hover:opacity-100 text-lg leading-none"
>
×
</button>
</div>
);
}

export function UndoToast() {
const toasts = useUndoStore((s) => s.toasts);

if (toasts.length === 0) return null;

return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2 w-full max-w-sm px-4">
{toasts.map((t) => (
<ToastItem key={t.id} {...t} />
))}
</div>
);
}
64 changes: 64 additions & 0 deletions src/pages/Contacts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { useUndoStore } from '@/stores/undoStore';

interface Contact {
id: string;
name: string;
address: string;
}

const INITIAL_CONTACTS: Contact[] = [
{ id: '1', name: 'Alice', address: 'GBVR...XYZW' },
{ id: '2', name: 'Bob', address: 'GCBA...MNOP' },
{ id: '3', name: 'Carol', address: 'GDZT...QRST' },
];

export default function Contacts() {
const [contacts, setContacts] = useState<Contact[]>(INITIAL_CONTACTS);
const addToast = useUndoStore((s) => s.addToast);

const deleteContact = (contact: Contact) => {
const previous = [...contacts];
setContacts((c) => c.filter((x) => x.id !== contact.id));
addToast(`Deleted "${contact.name}"`, () => {
setContacts(previous);
});
};

return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-3">
<h1 className="font-heading text-[28px] font-bold uppercase tracking-tight text-on-surface">
Contacts
</h1>
<p className="font-body text-sm leading-relaxed text-on-surface-variant">
Manage your saved contacts.
</p>
</section>
<ul className="flex flex-col gap-3">
{contacts.length === 0 && (
<p className="font-body text-sm text-on-surface-variant">No contacts yet.</p>
)}
{contacts.map((contact) => (
<li
key={contact.id}
className="flex items-center justify-between rounded-xl bg-surface-variant px-4 py-3"
>
<div className="flex flex-col">
<span className="font-body text-sm font-semibold text-on-surface">
{contact.name}
</span>
<span className="font-mono text-xs text-on-surface-variant">{contact.address}</span>
</div>
<button
onClick={() => deleteContact(contact)}
className="font-body text-sm text-error hover:underline"
>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
105 changes: 105 additions & 0 deletions src/pages/Notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { useUndoStore } from '@/stores/undoStore';

interface Notification {
id: string;
title: string;
body: string;
read: boolean;
}

const INITIAL_NOTIFICATIONS: Notification[] = [
{ id: '1', title: 'Payment received', body: 'You received 10 XLM from Alice.', read: false },
{ id: '2', title: 'Contact added', body: 'Bob was added to your contacts.', read: false },
{
id: '3',
title: 'Schedule executed',
body: 'Recurring payment to Carol completed.',
read: true,
},
];

export default function Notifications() {
const [notifications, setNotifications] = useState<Notification[]>(INITIAL_NOTIFICATIONS);
const [filter, setFilter] = useState<'all' | 'unread'>('all');
const addToast = useUndoStore((s) => s.addToast);

const dismiss = (notification: Notification) => {
const previous = [...notifications];
setNotifications((n) => n.filter((x) => x.id !== notification.id));
addToast(`Dismissed "${notification.title}"`, () => {
setNotifications(previous);
});
};

const clearFilter = () => {
const previousFilter = filter;
setFilter('all');
addToast('Filter cleared', () => {
setFilter(previousFilter);
});
};

const filtered = filter === 'unread' ? notifications.filter((n) => !n.read) : notifications;

return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-3">
<h1 className="font-heading text-[28px] font-bold uppercase tracking-tight text-on-surface">
Notifications
</h1>
<div className="flex items-center gap-3">
<div className="flex gap-2">
{(['all', 'unread'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`font-body text-sm px-3 py-1 rounded-lg capitalize ${
filter === f
? 'bg-primary text-on-primary'
: 'bg-surface-variant text-on-surface-variant'
}`}
>
{f}
</button>
))}
</div>
{filter !== 'all' && (
<button
onClick={clearFilter}
className="font-body text-sm text-on-surface-variant hover:underline"
>
Clear filter
</button>
)}
</div>
</section>
<ul className="flex flex-col gap-3">
{filtered.length === 0 && (
<p className="font-body text-sm text-on-surface-variant">No notifications.</p>
)}
{filtered.map((n) => (
<li
key={n.id}
className="flex items-start justify-between rounded-xl bg-surface-variant px-4 py-3"
>
<div className="flex flex-col gap-1">
<span
className={`font-body text-sm font-semibold ${n.read ? 'text-on-surface-variant' : 'text-on-surface'}`}
>
{n.title}
</span>
<span className="font-body text-xs text-on-surface-variant">{n.body}</span>
</div>
<button
onClick={() => dismiss(n)}
className="font-body text-sm text-on-surface-variant hover:underline ml-4 shrink-0"
>
Dismiss
</button>
</li>
))}
</ul>
</div>
);
}
32 changes: 32 additions & 0 deletions src/stores/undoStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { create } from 'zustand';

export interface UndoEntry {
id: string;
message: string;
onUndo: () => void;
expiresAt: number;
}

interface UndoState {
toasts: UndoEntry[];
addToast: (message: string, onUndo: () => void) => string;
removeToast: (id: string) => void;
}

export const useUndoStore = create<UndoState>((set) => ({
toasts: [],
addToast: (message, onUndo) => {
const id = Math.random().toString(36).substring(2, 9);
const expiresAt = Date.now() + 5000;
set((state) => ({
toasts: [...state.toasts, { id, message, onUndo, expiresAt }],
}));
setTimeout(() => {
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
}, 5000);
return id;
},
removeToast: (id) => {
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
},
}));
Loading