diff --git a/src/components/common/ActivePriceAlertsList.tsx b/src/components/common/ActivePriceAlertsList.tsx new file mode 100644 index 00000000..54f4f22c --- /dev/null +++ b/src/components/common/ActivePriceAlertsList.tsx @@ -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(null); + const [submitError, setSubmitError] = useState(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 ( +
+ Loading alerts… +
+ ); + } + + if (isError) { + return ( +
+ Could not load your price alerts. Try refreshing. +
+ ); + } + + if (!alerts || alerts.length === 0) { + return ( +
+ No active price alerts. Open any creator key page and choose "Set price alert" + to add one. +
+ ); + } + + const currentPrice = editing + ? resolveCurrentPrice?.(editing.keyId) ?? editing.targetPrice + : 0; + + return ( +
+ {alerts.map(alert => ( +
+
+

+ {alert.keyTitle ?? alert.keyName ?? alert.keyId} +

+

+ Notify when price goes{' '} + {alert.direction}{' '} + {alert.targetPrice} XLM +

+
+
+ + +
+
+ ))} + + {editing && ( + { + 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} + /> + )} +
+ ); +} + +export default ActivePriceAlertsList; diff --git a/src/components/common/PriceAlertButton.tsx b/src/components/common/PriceAlertButton.tsx new file mode 100644 index 00000000..3ac93620 --- /dev/null +++ b/src/components/common/PriceAlertButton.tsx @@ -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(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 ( + <> + + + + ); +} + +export default PriceAlertButton; diff --git a/src/components/common/PriceAlertModal.tsx b/src/components/common/PriceAlertModal.tsx new file mode 100644 index 00000000..e2bb3a0a --- /dev/null +++ b/src/components/common/PriceAlertModal.tsx @@ -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; + 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( + existingAlert?.direction ?? 'above' + ); + const [targetPrice, setTargetPrice] = useState( + 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 ( + + +
+ + + {isEditing ? 'Edit price alert' : 'Set price alert'} + + + Get an in-app notification when {keyName}'s key price crosses + your threshold. Current price:{' '} + {currentPrice} XLM. + + + +
+
+ Direction + + +
+ + { + setTargetPrice(value); + setTouched(true); + }} + error={touched && validationError ? validationError : ''} + touched={touched} + required + /> + + {submitError && ( + + )} +
+ + + + + +
+
+
+ ); +} + +export default PriceAlertModal; diff --git a/src/hooks/usePriceAlerts.ts b/src/hooks/usePriceAlerts.ts new file mode 100644 index 00000000..62246b07 --- /dev/null +++ b/src/hooks/usePriceAlerts.ts @@ -0,0 +1,88 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + alertService, + type AlertDirection, + type PriceAlert, +} from '@/services/alert.service'; + +export interface CreatePriceAlertInput { + keyId: string; + keyName?: string; + targetPrice: number; + direction: AlertDirection; +} + +export interface UpdatePriceAlertInput { + alertId: string; + targetPrice: number; + direction: AlertDirection; +} + +/** + * Fetch the current user's active price alerts. Polls every 60s so a + * backend-triggered alert disappears from the list promptly. + */ +export function useActivePriceAlerts(userId: string | undefined) { + return useQuery({ + queryKey: queryKeys.alerts.active(userId ?? ''), + queryFn: () => alertService.getActiveAlerts(userId as string), + enabled: Boolean(userId), + refetchInterval: 60000, + }); +} + +/** Create a price alert and refresh the active list. */ +export function useCreatePriceAlert(userId: string | undefined) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CreatePriceAlertInput) => alertService.createAlert(input), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.alerts.active(userId ?? ''), + }); + }, + }); +} + +/** Update an existing alert's target price and/or direction. */ +export function useUpdatePriceAlert(userId: string | undefined) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ alertId, targetPrice, direction }: UpdatePriceAlertInput) => + alertService.updateAlert(alertId, { targetPrice, direction }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.alerts.active(userId ?? ''), + }); + }, + }); +} + +/** Delete an alert. Optimistically removes it from the active list. */ +export function useDeletePriceAlert(userId: string | undefined) { + const queryClient = useQueryClient(); + const queryKey = queryKeys.alerts.active(userId ?? ''); + return useMutation({ + mutationFn: (alertId: string) => alertService.deleteAlert(alertId), + onMutate: async (alertId: string) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + if (previous) { + queryClient.setQueryData( + queryKey, + previous.filter(alert => alert.id !== alertId) + ); + } + return { previous }; + }, + onError: (_err, _id, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); +} diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 514fc79f..2d6aa9d6 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -15,10 +15,13 @@ import CreatorProfileStatRow from '@/components/common/CreatorProfileStatRow'; import BondingCurveChart from '@/components/common/BondingCurveChart'; import KeySimulationTool from '@/components/common/KeySimulationTool'; import BuyCooldownCountdown from '@/components/common/BuyCooldownCountdown'; +import KeyHolderList from '@/components/common/KeyHolderList'; +import HolderConcentrationChart from '@/components/common/HolderConcentrationChart'; import StakingRewardsSection from '@/components/common/StakingRewardsSection'; import DeprecationNotice from '@/components/common/DeprecationNotice'; import DeprecationBanner from '@/components/common/DeprecationBanner'; import SubscriptionAccessGate from '@/components/common/SubscriptionAccessGate'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; import { isKeyDeprecated } from '@/utils/keyDeprecation.utils'; import { Button } from '@/components/ui/button'; import { CreatorDashboardSkeleton } from '@/components/common/CreatorSkeleton'; @@ -68,6 +71,7 @@ import { usePerformanceBond } from '@/hooks/usePerformanceBond'; import PerformanceBondPanel from '@/components/common/PerformanceBondPanel'; import WhitelistStatusBadge from '@/components/common/WhitelistStatusBadge'; import ShareModal from '@/components/common/ShareModal'; +import PriceAlertButton from '@/components/common/PriceAlertButton'; import { useTradeCooldownStatus, invalidateTradeCooldownStatus, @@ -78,9 +82,6 @@ import { type ActiveTradeCooldown, } from '@/utils/tradeCooldown.utils'; import TradeCooldownButton from '@/components/common/TradeCooldownButton'; -import KeyHolderList from '@/components/common/KeyHolderList'; -import HolderConcentrationChart from '@/components/common/HolderConcentrationChart'; -import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; import { useAccount } from 'wagmi'; function CreatorDetailPageContent() { @@ -96,9 +97,8 @@ function CreatorDetailPageContent() { const [recentSettlement, setRecentSettlement] = useState(null); const [shareModalOpen, setShareModalOpen] = useState(false); - const [lastPurchasedAmount, setLastPurchasedAmount] = useState< - number | null - >(null); + const [lastPurchasedAmount, setLastPurchasedAmount] = + useState(null); const [deprecationDismissed, setDeprecationDismissed] = useState(false); const [buyDialogOpen, setBuyDialogOpen] = useState(false); const [tradeSubmitting, setTradeSubmitting] = useState(false); @@ -142,11 +142,6 @@ function CreatorDetailPageContent() { const { holders, hasNextPage, isFetchingNextPage, fetchNextPage } = useKeyHolders(id || ''); - /* - * Wallet integration: - * Prefer the currently connected wagmi wallet, then fall back to the - * profile-store address for compatibility with the existing application. - */ const profile = useProfileStore(state => state.profile); const { address: connectedWalletAddress } = useAccount(); const userAddress = connectedWalletAddress ?? profile?.id; @@ -156,11 +151,6 @@ function CreatorDetailPageContent() { const userPosition = holdings.find(h => h.creatorId === (id || '')); const holdingsCount = userPosition?.quantity ?? 0; - /* - * Per-user buy cooldown: - * Prefer the user's position-level value and fall back to the creator-wide - * value returned by the creator detail endpoint. - */ const nextBuyAllowedAt = userPosition?.nextBuyAllowedAt ?? creator?.nextBuyAllowedAt ?? null; @@ -199,9 +189,6 @@ function CreatorDetailPageContent() { const performanceBond = performanceBondData ?? creator?.performanceBond ?? null; - /* - * Pre-launch auction phase. - */ const { phase: auctionPhase } = useAuctionPhase({ auctionPrice: creator?.auctionPrice ?? null, auctionSupply: creator?.auctionSupply ?? null, @@ -209,32 +196,26 @@ function CreatorDetailPageContent() { auctionEndsAt: creator?.auctionEndsAt ?? null, }); - /* - * Early-access / whitelist logic. - * - * Supports both the newer whitelist fields from the maintainer and - * the early-access fields from your branch. - */ const isWhitelistGateActive = Boolean( creator?.isWhitelistEnabled ?? - creator?.whitelistEnabled ?? - creator?.earlyAccessEnabled ?? - false + creator?.whitelistEnabled ?? + creator?.earlyAccessEnabled ?? + false ); const whitelistEntries = creator?.whitelist ?? []; const isUserWhitelisted = Boolean( userAddress && - (whitelistEntries.some( - entry => - entry.walletAddress?.toUpperCase() === userAddress.toUpperCase() - ) || - (creator?.earlyAccessWhitelist ?? []).some( - address => address.toLowerCase() === userAddress.toLowerCase() + (whitelistEntries.some( + entry => + entry.walletAddress?.toUpperCase() === userAddress.toUpperCase() ) || - (creator?.instructorId && - creator.instructorId.toUpperCase() === userAddress.toUpperCase())) + (creator?.earlyAccessWhitelist ?? []).some( + address => address.toLowerCase() === userAddress.toLowerCase() + ) || + (creator?.instructorId && + creator.instructorId.toUpperCase() === userAddress.toUpperCase())) ); const publicLaunchTimestamp = creator?.publicLaunchDate @@ -262,9 +243,6 @@ function CreatorDetailPageContent() { ? 'Early access is restricted to approved whitelisted wallets.' : undefined; - /* - * On-chain metadata. - */ const { data: onChainMetadata, isLoading: isOnChainLoading, @@ -296,9 +274,6 @@ function CreatorDetailPageContent() { const displayAvatar = resolveIpfsUrl(rawAvatar) || creator?.avatarUri || creator?.thumbnail; - /* - * Track stale data indicator, including the on-chain metadata fallback. - */ const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator( id || '', isFetching || isOnChainLoading, @@ -308,18 +283,8 @@ function CreatorDetailPageContent() { } ); - const showStaleIndicator = shouldShowBadge || isFallbackActive; - - /* - * Trade mutation. - * - * Use the connected wallet rather than a hard-coded demo wallet. - */ const tradeMutation = useTradeMutation(connectedWalletAddress ?? ''); - /* - * Trade cooldown for this key. - */ const { data: tradeCooldownStatus } = useTradeCooldownStatus(id || ''); const tradeCooldown: ActiveTradeCooldown | null = resolveActiveTradeCooldown( @@ -367,7 +332,6 @@ function CreatorDetailPageContent() { if (id) { invalidateTradeCooldownStatus(queryClient, id); } - setTradeSubmitting(false); } }; @@ -420,10 +384,6 @@ function CreatorDetailPageContent() { }, ]; - /* - * During the pre-launch auction phase, show the current highest bid - * instead of the bonding-curve spot price. - */ const auctionLeadBid = auctionPhase === 'active' ? resolveHighestBid(creator.auctionBids, creator.auctionHighestBid) @@ -513,7 +473,6 @@ function CreatorDetailPageContent() { currentLabel={`${creator.title} Profile`} /> - {/* Key deprecation notice & guaranteed buyback flow */} {isKeyDeprecated(creator) && ( )} - {/* Merge proposal voting for source key holders */}
{ void refetch(); @@ -574,7 +532,6 @@ function CreatorDetailPageContent() { />
- {/* Historical Price Chart */} - {/* 4 Stat Cards */}
- {/* Key Stats Panel */} - {/* Performance Bond Status Panel */} - {/* Deprecation Notice */} {isKeyDeprecated(creator) && (
)} - {/* Key Purchase CTA */}
@@ -635,7 +587,6 @@ function CreatorDetailPageContent() { : 'Purchase keys for this creator.'}

- {/* Configurable bid-ask spread */} - {/* Oracle reference price */} - {/* Early access launch information */} {creator.earlyAccessEnabled && ( Early access @@ -678,45 +627,53 @@ function CreatorDetailPageContent() { )}
- {isKeyDeprecated(creator) ? ( - - ) : buyDisabledReason ? ( - - + + + {isKeyDeprecated(creator) ? ( + - - - ) : ( - setBuyDialogOpen(true)} - buttonProps={{ - 'data-testid': 'key-detail-buy-button', - }} - /> - )} + + + + ) : ( + setBuyDialogOpen(true)} + buttonProps={{ + 'data-testid': 'key-detail-buy-button', + }} + /> + )} +
- {/* Pre-Launch Auction Phase */} {auctionPhase !== 'inactive' && ( )} - {/* Buy Cooldown Countdown */} {userAddress && !isTradeCooldownActive && ( )} - {/* Share to X */}
- {/* TWAP */} {isTwapLoading ? (
) : null} - {/* Staking Rewards */} - {/* Price Curve */} {auctionPhase !== 'active' && ( <>
- {/* Graduated Curve Milestones */} - {/* Buy Simulation Tool */} )} - {/* Holder Concentration */}
- {/* Fee Structure */}

@@ -887,7 +835,6 @@ function CreatorDetailPageContent() {

- {/* Co-Creator Section */} - {/* Key Holders */}
- {/* Gated Content */}

Exclusive Content @@ -932,15 +877,14 @@ function CreatorDetailPageContent() { >

- 🎉 Welcome to the exclusive content section! Here you - can access premium videos, articles, and perks from{' '} + 🎉 Welcome to the exclusive content section! Here you can access + premium videos, articles, and perks from{' '} {creator.title || creator.name || 'this creator'}.

- {/* Activity */}

Activity @@ -951,7 +895,6 @@ function CreatorDetailPageContent() {

- {/* Key Buyback Modal */} {isKeyDeprecated(creator) && ( { setRecentSettlement(receipt); }} - /> + /> )} - {/* Trade Dialog */} {creator && ( )} - {/* Share Modal */} {creator && ( )} + {/* Price alerts panel (#1057) */} + {activeTab === 'price-alerts' && ( +
+
+

Price Alerts

+ +
+
+ )} + {/* Activity feed panel */} {activeTab === 'activity' && (
{ + try { + const response = await this.api.get< + APIResponse + >(`/alerts`, { params: { userId, active: true } }); + + const raw = response.data.data; + if (Array.isArray(raw)) return raw; + if (raw && typeof raw === 'object' && 'alerts' in raw && Array.isArray(raw.alerts)) { + return raw.alerts; + } + return []; + } catch (error) { + throw this.handleError(error); + } + } + + /** Mark alert as triggered on backend — PATCH /alerts/:alertId/triggered */ /** Create a new price alert - POST /alerts */ + async createAlert(input: { keyId: string; keyName?: string; targetPrice: number; direction: AlertDirection }): Promise { + try { + const response = await this.api.post>('/alerts', input); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } -export type AlertDirection = 'above' | 'below'; + /** Update an existing alert - PATCH /alerts/:alertId */ + async updateAlert(alertId: string, input: { targetPrice: number; direction: AlertDirection }): Promise { + try { + const response = await this.api.patch>('/alerts/' + alertId, input); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } -export interface PriceAlert { - id: string; - userId?: string; - keyId: string; - keyName?: string; - keyTitle?: string; - targetPrice: number; - direction: AlertDirection; - triggered?: boolean; - createdAt?: string; -} + /** Delete an alert - DELETE /alerts/:alertId */ + async deleteAlert(alertId: string): Promise { + try { + await this.api.delete('/alerts/' + alertId); + } catch (error) { + throw this.handleError(error); + } + } -class AlertService extends BaseApiService { - /** Fetch user's active price alerts — GET /alerts */ - async getActiveAlerts(userId: string): Promise { - try { - const response = await this.api.get< - APIResponse - >(`/alerts`, { params: { userId, active: true } }); - - const raw = response.data.data; - if (Array.isArray(raw)) return raw; - if (raw && typeof raw === 'object' && 'alerts' in raw && Array.isArray(raw.alerts)) { - return raw.alerts; - } - return []; - } catch (error) { - throw this.handleError(error); - } - } - - /** Mark alert as triggered on backend — PATCH /alerts/:alertId/triggered */ - async markAlertTriggered(alertId: string): Promise { - try { - await this.api.patch(`/alerts/${alertId}/triggered`); - } catch (error) { - throw this.handleError(error); - } - } -} - -export const alertService = new AlertService(); + async markAlertTriggered(alertId: string): Promise { + try { + await this.api.patch(`/alerts/${alertId}/triggered`); + } catch (error) { + throw this.handleError(error); + } + } +} + +export const alertService = new AlertService();