Skip to content
Closed
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
64 changes: 64 additions & 0 deletions Frontend/src/api/gists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// =============================================================================
// Gists API — typed client for the VertexChain backend POST /gists endpoint.
// =============================================================================

export interface GistPayload {
content: string;
lat: number;
lon: number;
author?: string;
}

export interface GistResponse {
id: string;
content: string;
lat: number;
lon: number;
author: string | null;
created_at: string;
stellar_gist_id?: string | null;
}

export class GistApiError extends Error {
constructor(
message: string,
public readonly status?: number,
) {
super(message);
this.name = "GistApiError";
}
}

const BASE_URL =
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";

/**
* POST a new gist to the backend.
*
* Returns the server-confirmed gist on success.
* Throws a {@link GistApiError} on network failure or non-2xx response.
*/
export async function postGist(payload: GistPayload): Promise<GistResponse> {
const url = `${BASE_URL}/gists`;

const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});

if (!res.ok) {
let body: string;
try {
body = await res.text();
} catch {
body = "Unable to read response body";
}
throw new GistApiError(
`POST /gists failed (${res.status}): ${body}`,
res.status,
);
}

return res.json() as Promise<GistResponse>;
}
27 changes: 16 additions & 11 deletions Frontend/src/components/map/AddGistModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,15 @@ vi.mock('framer-motion', () => ({
}))

const onClose = vi.fn()
const onAddGist = vi.fn()
const onAddGist = vi.fn().mockResolvedValue(undefined)

const renderModal = (isOpen = true) =>
render(<AddGistModal isOpen={isOpen} onClose={onClose} onAddGist={onAddGist} />)

describe('AddGistModal', () => {
beforeEach(() => {
vi.useFakeTimers()
onClose.mockClear()
onAddGist.mockClear()
})

afterEach(() => {
vi.useRealTimers()
onAddGist.mockClear().mockResolvedValue(undefined)
})

it('does not render the dialog when isOpen is false', () => {
Expand Down Expand Up @@ -55,28 +50,38 @@ describe('AddGistModal', () => {
it('does not call onAddGist when submitted with empty content', () => {
renderModal()
fireEvent.click(screen.getByRole('button', { name: /pin gist/i }))
act(() => { vi.advanceTimersByTime(2000) })
expect(onAddGist).not.toHaveBeenCalled()
})

it('shows loading state while submitting', () => {
it('shows loading state while submitting', async () => {
// Make onAddGist never resolve so we can observe the loading state
const deferred = new Promise<void>(() => {})
onAddGist.mockReturnValue(deferred)

renderModal()
fireEvent.change(screen.getByRole('textbox', { name: /gist content/i }), {
target: { value: 'Traffic jam on the bridge!' },
})
fireEvent.click(screen.getByRole('button', { name: /pin gist/i }))

// Loading state should appear immediately
expect(screen.getByRole('button', { name: /pinning/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /pinning/i })).toBeDisabled()
})

it('calls onAddGist with the content and then onClose after 2s', () => {
it('calls onAddGist with the content and then onClose on success', async () => {
onAddGist.mockResolvedValue(undefined)

renderModal()
fireEvent.change(screen.getByRole('textbox', { name: /gist content/i }), {
target: { value: 'Amazing suya spot just opened here!' },
})
fireEvent.click(screen.getByRole('button', { name: /pin gist/i }))

act(() => { vi.advanceTimersByTime(2000) })
// Wait for the async handler to complete
await act(async () => {
await Promise.resolve()
})

expect(onAddGist).toHaveBeenCalledWith('Amazing suya spot just opened here!')
expect(onClose).toHaveBeenCalledTimes(1)
Expand Down
17 changes: 9 additions & 8 deletions Frontend/src/components/map/AddGistModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { motion, AnimatePresence } from 'framer-motion';
interface AddGistModalProps {
isOpen: boolean;
onClose: () => void;
onAddGist: (content: string) => void;
onAddGist: (content: string) => Promise<void>;
}

export default function AddGistModal({
Expand Down Expand Up @@ -45,17 +45,18 @@ export default function AddGistModal({
return () => document.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);

const handleSubmit = useCallback(() => {
if (!content.trim()) return;
const handleSubmit = useCallback(async () => {
if (!content.trim() || isLoading) return;

setIsLoading(true);
setTimeout(() => {
onAddGist(content);
try {
await onAddGist(content);
setContent('');
setIsLoading(false);
onClose();
}, 2000);
}, [content, onAddGist, onClose]);
} finally {
setIsLoading(false);
}
}, [content, isLoading, onAddGist, onClose]);

return (
<AnimatePresence>
Expand Down
103 changes: 91 additions & 12 deletions Frontend/src/components/map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import '@/styles/leaflet-dark.css';
import { icon } from 'leaflet';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Plus } from 'lucide-react';
import AddGistModal from './AddGistModal';
import { motion } from 'framer-motion';
import { motion, AnimatePresence } from 'framer-motion';
import { postGist, GistApiError } from '@/api/gists';

export interface Gist {
id: number;
id: string | number;
content: string;
lat: number;
lng: number;
status?: 'pending' | 'delivered' | 'error';
}

const greenIcon = icon({
Expand Down Expand Up @@ -86,15 +88,72 @@ export default function Map() {
);
}, []);

const handleAddGist = (content: string) => {
const newGist: Gist = {
id: Date.now(),
content,
lat: position[0],
lng: position[1],
};
setGists((prevGists) => [...prevGists, newGist]);
};
const [toast, setToast] = useState<{
message: string;
type: 'success' | 'error';
} | null>(null);

const showToast = useCallback(
(message: string, type: 'success' | 'error') => {
setToast({ message, type });
setTimeout(() => setToast(null), 4000);
},
[],
);

const handleAddGist = useCallback(
async (content: string) => {
// Create an optimistic gist with a temporary client-side id
const tempId = `optimistic-${Date.now()}`;
const optimisticGist: Gist = {
id: tempId,
content,
lat: position[0],
lng: position[1],
status: 'pending',
};

// Optimistic insert — add immediately to local state
setGists((prevGists) => [...prevGists, optimisticGist]);

try {
const serverGist = await postGist({
content,
lat: position[0],
lon: position[1],
});

// Replace the optimistic entry with the server-confirmed one
setGists((prevGists) =>
prevGists.map((g) =>
g.id === tempId
? {
id: serverGist.id,
content: serverGist.content,
lat: serverGist.lat,
lng: serverGist.lon,
status: 'delivered' as const,
}
: g,
),
);

showToast('Gist pinned successfully!', 'success');
} catch (err) {
// Rollback — remove the optimistic entry on error
setGists((prevGists) =>
prevGists.filter((g) => g.id !== tempId),
);

const message =
err instanceof GistApiError
? err.message
: 'Network error — please try again.';
showToast(message, 'error');
}
},
[position, showToast],
);

return (
<div className="relative h-full w-full">
Expand All @@ -117,6 +176,26 @@ export default function Map() {
))}
</MapContainer>

{/* Toast notification */}
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
role="alert"
aria-live="assertive"
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[2000] px-6 py-3 rounded-lg shadow-xl text-white font-medium text-sm ${
toast.type === 'success'
? 'bg-green-600'
: 'bg-red-600'
}`}
>
{toast.message}
</motion.div>
)}
</AnimatePresence>

<motion.button
onClick={() => setIsModalOpen(true)}
// 3. Animation props for pulsing effect
Expand Down
70 changes: 53 additions & 17 deletions contracts/batch-wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,26 +224,36 @@ impl BatchWalletContract {
let mut successful: u32 = 0;
let mut failed: u32 = 0;

// Check for duplicates within the batch
// Track addresses seen in *this batch* to handle duplicates gracefully
let mut seen_addresses = Vec::new(&env);

// Process each request — duplicates within the batch yield
// WalletCreateResult::Failure(owner, DuplicateWallet) instead of
// panicking, so the rest of the batch can still succeed.
for i in 0..requests.len() {
let request = requests.get(i).unwrap();
let owner = request.owner.clone();

// Check if this address was already seen in this batch
// Check for duplicate within the batch
let mut is_duplicate = false;
for seen in seen_addresses.iter() {
if seen == owner {
BatchWalletEvents::wallet_duplicate(&env, &owner);
panic_with_error!(&env, BatchWalletError::DuplicateWallet);
is_duplicate = true;
break;
}
}
seen_addresses.push_back(owner);
}

// Process each request
for i in 0..requests.len() {
let request = requests.get(i).unwrap();
let owner = request.owner.clone();
if is_duplicate {
BatchWalletEvents::wallet_duplicate(&env, &owner);
results.push_back(WalletCreateResult::Failure(
owner,
BatchWalletError::DuplicateWallet as u32,
));
failed += 1;
continue;
}

seen_addresses.push_back(owner.clone());

if let Some(_existing_wallet) = get_wallet(&env, owner.clone()) {
results.push_back(WalletCreateResult::Failure(
Expand Down Expand Up @@ -503,20 +513,46 @@ mod tests {
}

#[test]
// BatchWalletError::DuplicateWallet = 7 (`#[repr(u32)]`) — keep in sync if enum is reordered.
#[should_panic(expected = "Error(Contract, #7)")]
fn test_batch_create_wallets_duplicate_in_batch() {
fn test_batch_create_wallets_partial_success_with_duplicates() {
// Send N+1 requests where M are duplicates → result reports failed=M,
// others succeed, event wallet_duplicate emitted per duplicate.
let (env, admin, client) = setup_test_env();

let owner = Address::generate(&env);
let owner1 = Address::generate(&env);
let owner2 = Address::generate(&env);
let owner3 = Address::generate(&env);

// 5 requests: owner1, owner2, owner1 (duplicate), owner3, owner2 (duplicate)
let mut requests: Vec<WalletCreateRequest> = Vec::new(&env);
requests.push_back(create_wallet_request(&env, owner.clone()));
requests.push_back(create_wallet_request(&env, owner1.clone()));
requests.push_back(create_wallet_request(&env, owner2.clone()));
requests.push_back(create_wallet_request(&env, owner.clone()));
requests.push_back(create_wallet_request(&env, owner1.clone())); // duplicate
requests.push_back(create_wallet_request(&env, owner3.clone()));
requests.push_back(create_wallet_request(&env, owner2.clone())); // duplicate

let result = client.batch_create_wallets(&admin, &requests);

client.batch_create_wallets(&admin, &requests);
// 5 total, 3 unique → 3 succeed, 2 duplicates fail
assert_eq!(result.total_requests, 5);
assert_eq!(result.successful, 3);
assert_eq!(result.failed, 2);
assert_eq!(result.results.len(), 5);

// Verify wallets were created for unique owners only
assert!(client.get_wallet(&owner1).is_some());
assert!(client.get_wallet(&owner2).is_some());
assert!(client.get_wallet(&owner3).is_some());

// Verify duplicate entries are Failure with DuplicateWallet code (7)
let mut duplicate_count = 0;
for r in result.results.iter() {
if let WalletCreateResult::Failure(_, code) = r {
if code == BatchWalletError::DuplicateWallet as u32 {
duplicate_count += 1;
}
}
}
assert_eq!(duplicate_count, 2);
}

#[test]
Expand Down
Loading
Loading