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
35 changes: 30 additions & 5 deletions src/components/AddressChangeRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from 'react';
import type { AddressChangeRequestInput } from '@/types/addressChangeRequest';
import { useAddressValidation } from '@/hooks/useAddressValidation';

interface AddressChangeRequestModalProps {
invoiceId: string;
Expand All @@ -25,25 +26,40 @@ export default function AddressChangeRequestModal({
});
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const {
validateAddress,
isValid: isAddressValid,
isLoading: isAddressValidating,
error: addressError,
} = useAddressValidation();

const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (name === 'newAddress' && hasAttemptedSubmit) {
validateAddress(value);
}
};

const handleAddressBlur = (e: React.FocusEvent<HTMLInputElement>) => {
validateAddress(e.target.value);
};

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
setHasAttemptedSubmit(true);

// Validate new address is different from old address
if (formData.newAddress === formData.oldAddress) {
setError('New address must be different from current address');
return;
}

// Validate new address format (basic check for Stellar address)
if (!formData.newAddress.startsWith('G') || formData.newAddress.length !== 56) {
setError('Invalid Stellar address format');
// Validate new address using Stellar address validation (state populated via blur/change)
if (!formData.newAddress.trim() || !isAddressValid) {
setError(addressError || 'Invalid Stellar address format');
return;
}

Expand Down Expand Up @@ -119,11 +135,16 @@ export default function AddressChangeRequestModal({
name="newAddress"
value={formData.newAddress}
onChange={handleChange}
onBlur={handleAddressBlur}
placeholder="G..."
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
<p className="text-xs text-gray-500 mt-1">Must be a valid Stellar address starting with G</p>
{addressError ? (
<p className="text-xs text-red-400 mt-1">{addressError}</p>
) : (
<p className="text-xs text-gray-500 mt-1">Must be a valid Stellar address starting with G</p>
)}
</div>

{/* Justification */}
Expand Down Expand Up @@ -158,7 +179,11 @@ export default function AddressChangeRequestModal({
</button>
<button
type="submit"
disabled={isLoading}
disabled={
isLoading ||
!formData.newAddress.trim() ||
(hasAttemptedSubmit && (!isAddressValid || isAddressValidating))
}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
{isLoading ? 'Submitting...' : 'Submit Request'}
Expand Down
28 changes: 27 additions & 1 deletion src/components/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
"use client";

import { useEffect } from "react";
import { useI18n } from "@/components/I18nProvider";
import type { Locale } from "@/lib/i18nTypes";

const STORAGE_KEY = "split_app_locale";
const VALID_LOCALES: Locale[] = ["en", "es", "pt", "fr"];

export default function LanguageSwitcher() {
const { locale, setLocale } = useI18n();

useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY) as Locale | null;
if (stored && VALID_LOCALES.includes(stored) && stored !== locale) {
setLocale(stored);
}
} catch {
// localStorage unavailable; keep default locale
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newLocale = e.target.value as Locale;
setLocale(newLocale);
try {
localStorage.setItem(STORAGE_KEY, newLocale);
} catch {
// localStorage unavailable; selection still applies for this session
}
};

return (
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
onChange={handleChange}
className="text-sm text-gray-400 hover:text-gray-200 px-2 py-1 rounded bg-gray-900 border border-gray-800 transition-colors min-h-11 inline-flex items-center"
aria-label="Select language"
>
Expand Down
83 changes: 82 additions & 1 deletion src/components/ProofViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,53 @@
"use client";

import { useState } from "react";
import { useRef, useState } from "react";
import { splitClient } from "@/lib/stellar";
import { formatAmount, truncateAddress } from "@stellar-split/sdk";

interface VerifiedProof {
payer: string;
amount: bigint;
ledger: number;
imageUrl?: string;
}

interface Props {
invoiceId: string;
}

const MIN_ZOOM = 0.5;
const MAX_ZOOM = 4;
const ZOOM_STEP = 0.25;

export default function ProofViewer({ invoiceId }: Props) {
const [proofHash, setProofHash] = useState("");
const [verifying, setVerifying] = useState(false);
const [verified, setVerified] = useState<VerifiedProof | null>(null);
const [error, setError] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef({ x: 0, y: 0 });

const zoomIn = () => setZoom((z) => Math.min(MAX_ZOOM, +(z + ZOOM_STEP).toFixed(2)));
const zoomOut = () => setZoom((z) => Math.max(MIN_ZOOM, +(z - ZOOM_STEP).toFixed(2)));
const resetZoom = () => {
setZoom(1);
setPan({ x: 0, y: 0 });
};

const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (zoom <= 1) return;
setIsDragging(true);
dragStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
};

const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging) return;
setPan({ x: e.clientX - dragStartRef.current.x, y: e.clientY - dragStartRef.current.y });
};

const handlePointerUp = () => setIsDragging(false);

const handleVerify = async () => {
if (!proofHash.trim()) return;
Expand Down Expand Up @@ -103,11 +132,63 @@ export default function ProofViewer({ invoiceId }: Props) {
</div>
</div>

{verified.imageUrl && (
<div className="space-y-2">
<div
className="relative h-64 overflow-hidden rounded-lg border border-gray-700 bg-gray-900"
style={{ cursor: zoom > 1 ? (isDragging ? "grabbing" : "grab") : "default" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<img
src={verified.imageUrl}
alt="Payment proof"
draggable={false}
className="h-full w-full object-contain select-none"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "center center",
}}
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={zoomOut}
disabled={zoom <= MIN_ZOOM}
aria-label="Zoom out"
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-700 text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={zoomIn}
disabled={zoom >= MAX_ZOOM}
aria-label="Zoom in"
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-700 text-sm font-medium hover:bg-gray-700 disabled:opacity-50"
>
+
</button>
<button
type="button"
onClick={resetZoom}
className="flex-1 px-3 py-1.5 rounded-lg border border-gray-700 text-sm font-medium hover:bg-gray-700"
>
Reset
</button>
</div>
</div>
)}

<button
type="button"
onClick={() => {
setVerified(null);
setProofHash("");
resetZoom();
}}
className="w-full px-4 py-2 rounded-lg border border-gray-700 text-sm font-medium hover:bg-gray-700"
>
Expand Down
21 changes: 17 additions & 4 deletions src/components/ToastContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,27 @@ export interface ToastMessage {
type: 'success' | 'error' | 'info';
}

export type ToastContainerPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';

export interface ToastContainerProps {
position?: ToastContainerPosition;
}

const POSITION_CLASSES: Record<ToastContainerPosition, string> = {
'top-right': 'top-4 right-4',
'top-left': 'top-4 left-4',
'bottom-right': 'bottom-4 right-4',
'bottom-left': 'bottom-4 left-4',
};

/**
* ToastContainer — manages a stack of toast notifications.
* Renders toasts in the bottom-right corner with auto-dismiss.
*
* Renders toasts at a configurable corner (default top-right) with auto-dismiss.
*
* Note: The project also has a ToastContext-based toast system.
* This component provides a lightweight alternative for direct use.
*/
export default function ToastContainer() {
export default function ToastContainer({ position = 'top-right' }: ToastContainerProps) {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const dedupeTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
const dismissTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
Expand Down Expand Up @@ -71,7 +84,7 @@ export default function ToastContainer() {

return (
<div
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm"
className={`fixed ${POSITION_CLASSES[position]} z-50 flex flex-col gap-2 max-w-sm`}
aria-live="polite"
aria-label="Notifications"
>
Expand Down
Loading