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
170 changes: 170 additions & 0 deletions src/components/common/PriceAlertDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { useEffect, useMemo, useState } from 'react';
import { BellRing } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { useCreatePriceAlert } from '@/hooks/usePriceAlerts';
import type { AlertDirection } from '@/services/alert.service';
import { cn } from '@/lib/utils';
import { parsePositivePrice } from '@/utils/priceAlert.utils';
import showToast from '@/utils/toast.util';

interface PriceAlertDialogProps {
userId?: string;
keyId: string;
keyName?: string;
currentPrice?: number | null;
}

export default function PriceAlertDialog({
userId,
keyId,
keyName = 'this key',
currentPrice,
}: PriceAlertDialogProps) {
const [open, setOpen] = useState(false);
const [targetText, setTargetText] = useState('');
const [direction, setDirection] = useState<AlertDirection>('above');
const createAlert = useCreatePriceAlert(userId);
const targetPrice = useMemo(() => parsePositivePrice(targetText), [targetText]);
const invalid = targetText.trim().length > 0 && targetPrice === null;

useEffect(() => {
if (!open) return;
setTargetText(currentPrice != null && currentPrice > 0 ? String(currentPrice) : '');
setDirection('above');
createAlert.reset();
}, [createAlert, currentPrice, open]);

const handleSave = async () => {
if (targetPrice === null) return;
try {
await createAlert.mutateAsync({ keyId, keyName, targetPrice, direction });
showToast.success(
`Price alert set for ${keyName}. We’ll notify you when the price moves ${direction} your target.`
);
setOpen(false);
} catch (error) {
showToast.error(
error instanceof Error ? error.message : 'Couldn’t save your price alert.'
);
}
};

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
className="border-white/20 bg-white/[0.04] text-white hover:bg-white/10"
data-testid="set-alert-button"
>
<BellRing className="size-4" aria-hidden="true" />
Set Alert
</Button>
</DialogTrigger>
<DialogContent className="border-white/10 bg-[#0b1728] text-white">
<DialogHeader>
<DialogTitle className="text-white">Set a price alert</DialogTitle>
<DialogDescription className="text-white/60">
We’ll check {keyName} every 60 seconds and notify you when it crosses your target.
</DialogDescription>
</DialogHeader>

<form
className="space-y-5"
onSubmit={event => {
event.preventDefault();
void handleSave();
}}
>
<div className="space-y-2">
<label htmlFor="price-alert-target" className="text-sm font-medium text-white/80">
Target price
</label>
<input
id="price-alert-target"
type="number"
inputMode="decimal"
min="0"
step="any"
value={targetText}
onChange={event => setTargetText(event.target.value)}
aria-invalid={invalid}
aria-describedby="price-alert-help price-alert-error"
placeholder="Enter a positive price"
className={cn(
'w-full rounded-xl border border-white/15 bg-white/[0.05] px-4 py-3 text-white outline-none transition focus:border-cyan-300 focus:ring-2 focus:ring-cyan-300/20',
invalid && 'border-rose-400 focus:border-rose-400 focus:ring-rose-400/20'
)}
/>
<p id="price-alert-help" className="text-xs text-white/45">
Use the key’s quoted unit, XLM.
</p>
{invalid ? (
<p id="price-alert-error" role="alert" className="text-xs text-rose-300">
Enter a target price greater than zero.
</p>
) : null}
</div>

<fieldset>
<legend className="mb-2 text-sm font-medium text-white/80">Notify me when price is</legend>
<div className="grid grid-cols-2 gap-2 rounded-xl bg-white/[0.04] p-1">
{(['above', 'below'] as const).map(option => (
<button
key={option}
type="button"
onClick={() => setDirection(option)}
aria-pressed={direction === option}
className={cn(
'rounded-lg px-4 py-2 text-sm font-semibold capitalize transition',
direction === option
? 'bg-cyan-400 text-slate-950 shadow'
: 'text-white/60 hover:bg-white/5 hover:text-white'
)}
>
{option}
</button>
))}
</div>
</fieldset>

{createAlert.isError ? (
<p role="alert" className="text-sm text-rose-300">
{createAlert.error instanceof Error
? createAlert.error.message
: 'Couldn’t save your price alert.'}
</p>
) : null}

<DialogFooter>
<Button
type="button"
variant="ghost"
className="text-white/70 hover:text-white"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={targetPrice === null || createAlert.isPending}
className="bg-cyan-400 text-slate-950 hover:bg-cyan-300"
>
{createAlert.isPending ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
97 changes: 97 additions & 0 deletions src/components/common/PriceAlertsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { BellRing, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useActivePriceAlerts, useDeletePriceAlert } from '@/hooks/usePriceAlerts';
import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
import showToast from '@/utils/toast.util';

interface PriceAlertsSectionProps {
userId: string | undefined;
}

export default function PriceAlertsSection({ userId }: PriceAlertsSectionProps) {
const alertsQuery = useActivePriceAlerts(userId);
const deleteAlert = useDeletePriceAlert(userId);
const alerts = alertsQuery.data ?? [];

const handleDelete = async (alertId: string) => {
try {
await deleteAlert.mutateAsync(alertId);
showToast.success('Price alert deleted.');
} catch (error) {
showToast.error(error instanceof Error ? error.message : 'Couldn’t delete this alert.');
}
};

return (
<section aria-labelledby="my-alerts-heading" data-testid="my-alerts-section">
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6 md:p-8">
<div className="mb-6 flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-cyan-400/10 text-cyan-300">
<BellRing className="size-5" aria-hidden="true" />
</span>
<div>
<h2 id="my-alerts-heading" className="font-grotesque text-2xl font-bold text-white">
My Alerts
</h2>
<p className="mt-1 text-sm text-white/60">
Price thresholds we’re watching for you.
</p>
</div>
</div>

{alertsQuery.isLoading ? (
<div className="space-y-3" aria-label="Loading alerts" aria-busy="true">
{Array.from({ length: 2 }, (_, index) => (
<div key={index} className="h-20 animate-pulse rounded-xl bg-white/[0.05]" />
))}
</div>
) : alertsQuery.isError ? (
<div role="alert" className="rounded-xl border border-rose-400/20 bg-rose-400/[0.06] p-4 text-sm text-rose-200">
Couldn’t load your alerts.
<button type="button" onClick={() => void alertsQuery.refetch()} className="ml-2 font-semibold underline">
Try again
</button>
</div>
) : alerts.length === 0 ? (
<div className="rounded-xl border border-dashed border-white/10 py-10 text-center">
<p className="font-semibold text-white">No active alerts</p>
<p className="mt-1 text-sm text-white/45">Set one from any key detail page.</p>
</div>
) : (
<ul className="space-y-3" data-testid="my-alerts-list">
{alerts.map(alert => (
<li
key={alert.id}
className="flex flex-col gap-3 rounded-xl border border-white/10 bg-white/[0.035] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="truncate font-semibold text-white">
{alert.keyName || `Key ${alert.keyId}`}
</p>
<p className="mt-1 text-sm text-white/60">
Alert when price is{' '}
<span className={alert.direction === 'above' ? 'text-emerald-300' : 'text-rose-300'}>
{alert.direction}
</span>{' '}
<span className="font-mono text-white">{formatDisplayKeyPrice(alert.targetPrice)}</span>
</p>
</div>
<Button
type="button"
variant="ghost"
disabled={deleteAlert.isPending}
onClick={() => void handleDelete(alert.id)}
className="shrink-0 self-start text-rose-300 hover:bg-rose-400/10 hover:text-rose-200 sm:self-auto"
aria-label={`Delete alert for ${alert.keyName || alert.keyId}`}
data-testid={`delete-alert-${alert.id}`}
>
<Trash2 className="size-4" aria-hidden="true" /> Delete
</Button>
</li>
))}
</ul>
)}
</div>
</section>
);
}
Loading
Loading