Skip to content
141 changes: 141 additions & 0 deletions src/components/common/ActivePriceAlertsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { useState } from 'react';
import { useActivePriceAlerts, useDeletePriceAlert, useUpdatePriceAlert } from '@/hooks/usePriceAlerts';
import PriceAlertModal from './PriceAlertModal';
import { Button } from '@/components/ui/button';
import type { PriceAlert } from '@/services/alert.service';

export interface ActivePriceAlertsListProps {
userId: string | undefined;
/** Optional resolver so the modal can show a current price when editing. */
resolveCurrentPrice?: (keyId: string) => number | undefined;
}

export function ActivePriceAlertsList({ userId, resolveCurrentPrice }: ActivePriceAlertsListProps) {
const { data: alerts, isLoading, isError } = useActivePriceAlerts(userId);
const updateAlert = useUpdatePriceAlert(userId);
const deleteAlert = useDeletePriceAlert(userId);
const [editing, setEditing] = useState<PriceAlert | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);

const handleDelete = async (alertId: string) => {
try {
await deleteAlert.mutateAsync(alertId);
} catch (err) {
setSubmitError((err as Error).message ?? 'Could not delete alert.');
}
};

const handleUpdate = async (input: { targetPrice: number; direction: 'above' | 'below' }) => {
if (!editing) return;
setSubmitError(null);
try {
await updateAlert.mutateAsync({
alertId: editing.id,
targetPrice: input.targetPrice,
direction: input.direction,
});
setEditing(null);
} catch (err) {
setSubmitError((err as Error).message ?? 'Could not update alert.');
}
};

if (isLoading) {
return (
<div data-testid="price-alerts-loading" className="text-sm text-white/60">
Loading alerts…
</div>
);
}

if (isError) {
return (
<div
data-testid="price-alerts-error"
className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-4 text-sm text-rose-200"
>
Could not load your price alerts. Try refreshing.
</div>
);
}

if (!alerts || alerts.length === 0) {
return (
<div
data-testid="price-alerts-empty"
className="rounded-xl border border-white/10 bg-white/[0.02] p-6 text-sm text-white/60"
>
No active price alerts. Open any creator key page and choose "Set price alert"
to add one.
</div>
);
}

const currentPrice = editing
? resolveCurrentPrice?.(editing.keyId) ?? editing.targetPrice
: 0;

return (
<div data-testid="price-alerts-list" className="space-y-3">
{alerts.map(alert => (
<div
key={alert.id}
data-testid={`price-alert-row-${alert.id}`}
className="flex items-center justify-between gap-4 rounded-xl border border-white/10 bg-white/[0.02] px-4 py-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">
{alert.keyTitle ?? alert.keyName ?? alert.keyId}
</p>
<p className="text-xs text-white/60">
Notify when price goes{' '}
<strong>{alert.direction}</strong>{' '}
{alert.targetPrice} XLM
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
size="sm"
data-testid={`price-alert-edit-${alert.id}`}
onClick={() => {
setSubmitError(null);
setEditing(alert);
}}
>
Edit
</Button>
<Button
type="button"
variant="outline"
size="sm"
data-testid={`price-alert-delete-${alert.id}`}
onClick={() => handleDelete(alert.id)}
>
Delete
</Button>
</div>
</div>
))}

{editing && (
<PriceAlertModal
open={Boolean(editing)}
onOpenChange={(open: boolean) => {
if (!open) setEditing(null);
}}
keyId={editing.keyId}
keyName={editing.keyTitle ?? editing.keyName ?? editing.keyId}
currentPrice={currentPrice}
existingAlert={editing}
onSubmit={handleUpdate}
isSubmitting={updateAlert.isPending}
submitError={submitError}
/>
)}
</div>
);
}

export default ActivePriceAlertsList;
71 changes: 71 additions & 0 deletions src/components/common/PriceAlertButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useState } from 'react';
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PriceAlertModal from './PriceAlertModal';
import { useCreatePriceAlert } from '@/hooks/usePriceAlerts';

export interface PriceAlertButtonProps {
userId: string | undefined;
keyId: string;
keyName: string;
currentPrice: number;
className?: string;
}

export function PriceAlertButton({
userId,
keyId,
keyName,
currentPrice,
className,
}: PriceAlertButtonProps) {
const [open, setOpen] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const createAlert = useCreatePriceAlert(userId);

const handleSubmit = async (input: { targetPrice: number; direction: 'above' | 'below' }) => {
setSubmitError(null);
try {
await createAlert.mutateAsync({
keyId,
keyName,
targetPrice: input.targetPrice,
direction: input.direction,
});
setOpen(false);
} catch (err) {
setSubmitError((err as Error).message ?? 'Could not create alert.');
}
};

return (
<>
<Button
type="button"
variant="outline"
className={className}
data-testid="set-price-alert-button"
onClick={() => {
setSubmitError(null);
setOpen(true);
}}
disabled={!userId}
>
<Bell className="mr-2 h-4 w-4" aria-hidden="true" />
Set price alert
</Button>
<PriceAlertModal
open={open}
onOpenChange={setOpen}
keyId={keyId}
keyName={keyName}
currentPrice={currentPrice}
onSubmit={handleSubmit}
isSubmitting={createAlert.isPending}
submitError={submitError}
/>
</>
);
}

export default PriceAlertButton;
179 changes: 179 additions & 0 deletions src/components/common/PriceAlertModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { FormInput } from '@/components/common/FormInput';
import InlineValidationMessage from '@/components/common/InlineValidationMessage';
import type { AlertDirection, PriceAlert } from '@/services/alert.service';

export interface PriceAlertModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
keyId: string;
keyName: string;
currentPrice: number;
existingAlert?: PriceAlert | null;
onSubmit: (input: { targetPrice: number; direction: AlertDirection }) => Promise<void> | void;
isSubmitting?: boolean;
submitError?: string | null;
}

function validateTargetPrice(
raw: string,
direction: AlertDirection,
currentPrice: number
): string | null {
if (!raw.trim()) return 'Target price is required.';
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 'Target price must be a positive number.';
}
if (direction === 'above' && parsed <= currentPrice) {
return `An "above" alert needs a target greater than the current price (${currentPrice}).`;
}
if (direction === 'below' && parsed >= currentPrice) {
return `A "below" alert needs a target less than the current price (${currentPrice}).`;
}
return null;
}

export function PriceAlertModal({
open,
onOpenChange,
keyId,
keyName,
currentPrice,
existingAlert,
onSubmit,
isSubmitting = false,
submitError = null,
}: PriceAlertModalProps) {
void keyId;

const isEditing = Boolean(existingAlert);
const [direction, setDirection] = useState<AlertDirection>(
existingAlert?.direction ?? 'above'
);
const [targetPrice, setTargetPrice] = useState<string>(
existingAlert?.targetPrice != null ? String(existingAlert.targetPrice) : ''
);
const [touched, setTouched] = useState(false);

useEffect(() => {
if (!open) return;
setDirection(existingAlert?.direction ?? 'above');
setTargetPrice(
existingAlert?.targetPrice != null ? String(existingAlert.targetPrice) : ''
);
setTouched(false);
}, [open, existingAlert]);

const validationError = useMemo(
() => validateTargetPrice(targetPrice, direction, currentPrice),
[targetPrice, direction, currentPrice]
);
const canSubmit = validationError === null && !isSubmitting;

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setTouched(true);
if (!canSubmit) return;
await onSubmit({ targetPrice: Number(targetPrice), direction });
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="price-alert-modal">
<form onSubmit={handleSubmit} noValidate>
<DialogHeader>
<DialogTitle>
{isEditing ? 'Edit price alert' : 'Set price alert'}
</DialogTitle>
<DialogDescription>
Get an in-app notification when {keyName}&apos;s key price crosses
your threshold. Current price:{' '}
<strong data-testid="price-alert-current">{currentPrice} XLM</strong>.
</DialogDescription>
</DialogHeader>

<div className="mt-4 space-y-4">
<fieldset className="space-y-2" data-testid="price-alert-direction">
<legend className="text-sm font-semibold">Direction</legend>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="direction"
value="above"
checked={direction === 'above'}
onChange={() => setDirection('above')}
data-testid="direction-above"
/>
Alert me when price rises above target
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="direction"
value="below"
checked={direction === 'below'}
onChange={() => setDirection('below')}
data-testid="direction-below"
/>
Alert me when price falls below target
</label>
</fieldset>

<FormInput
label="Target price (XLM)"
id="price-alert-target"
type="number"
placeholder="0.00"
value={targetPrice}
onChange={value => {
setTargetPrice(value);
setTouched(true);
}}
error={touched && validationError ? validationError : ''}
touched={touched}
required
/>

{submitError && (
<InlineValidationMessage message={submitError} />
)}
</div>

<DialogFooter className="mt-6">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={!canSubmit}
data-testid="price-alert-submit"
>
{isSubmitting
? 'Saving…'
: isEditing
? 'Save changes'
: 'Create alert'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

export default PriceAlertModal;
Loading
Loading