diff --git a/src/App.tsx b/src/App.tsx index 648e41d..647efbb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,8 @@ import { DashboardPage } from '@/features/dashboard/DashboardPage'; import { LiveMarketDashboard } from '@/pages/LiveMarketDashboard'; import { AuthGuard } from '@/features/auth/AuthGuard'; import { useUserStore } from '@/stores/userStore'; +import { PriceAlertModal } from '@/components/PriceAlertModal'; +import { ActiveAlertsDrawer } from '@/components/ActiveAlertsDrawer'; import { ROUTES } from '@/constants/routes.constants'; function AppContent() { @@ -80,61 +82,67 @@ function AppContent() { } return ( - - {/* Landing */} - navigate(ROUTES.LOGIN)} />} /> + <> + + {/* Landing */} + navigate(ROUTES.LOGIN)} />} /> - {/* Auth Flows */} - navigate(ROUTES.DASHBOARD)} - onNavigateToSignup={() => navigate(ROUTES.REGISTER)} - onNavigateToForgot={() => navigate(ROUTES.FORGOT_PASSWORD)} - /> - } - /> - navigate(ROUTES.SUCCESS)} - onNavigateToLogin={() => navigate(ROUTES.LOGIN)} - /> - } - /> - navigate(ROUTES.LOGIN)} // Needs to login first - onBackToLogin={() => navigate(ROUTES.LOGIN)} - /> - } - /> - } /> - } /> - navigate(ROUTES.LOGIN)} />} - /> - navigate(ROUTES.LOGIN)} />} - /> - } /> + {/* Auth Flows */} + navigate(ROUTES.DASHBOARD)} + onNavigateToSignup={() => navigate(ROUTES.REGISTER)} + onNavigateToForgot={() => navigate(ROUTES.FORGOT_PASSWORD)} + /> + } + /> + navigate(ROUTES.SUCCESS)} + onNavigateToLogin={() => navigate(ROUTES.LOGIN)} + /> + } + /> + navigate(ROUTES.LOGIN)} // Needs to login first + onBackToLogin={() => navigate(ROUTES.LOGIN)} + /> + } + /> + } /> + } /> + navigate(ROUTES.LOGIN)} />} + /> + navigate(ROUTES.LOGIN)} />} + /> + } /> - {/* Protected Routes */} - - - - } - /> - + {/* Protected Routes */} + + + + } + /> + + + {/* Global Price Target Alarm Modal & Drawer */} + + + ); } diff --git a/src/components/ActiveAlertsDrawer.tsx b/src/components/ActiveAlertsDrawer.tsx new file mode 100644 index 0000000..d14f6b5 --- /dev/null +++ b/src/components/ActiveAlertsDrawer.tsx @@ -0,0 +1,169 @@ +import React, { useEffect } from 'react'; +import { X, Bell, Trash2, Plus, TrendingUp, TrendingDown, RefreshCw } from 'lucide-react'; +import { usePriceAlertStore } from '@/stores/priceAlertStore'; +import '@/styles/components/price-alerts.css'; + +export const ActiveAlertsDrawer: React.FC = () => { + const { + alerts, + isDrawerOpen, + setDrawerOpen, + fetchAlerts, + removeAlert, + openModal, + isLoading, + } = usePriceAlertStore(); + + useEffect(() => { + if (isDrawerOpen) { + fetchAlerts(); + } + }, [isDrawerOpen, fetchAlerts]); + + if (!isDrawerOpen) return null; + + const activeAlerts = alerts.filter((a) => !a.is_triggered); + const triggeredAlerts = alerts.filter((a) => a.is_triggered); + + const formatDate = (dateStr?: string | null) => { + if (!dateStr) return ''; + const d = new Date(dateStr); + return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + return ( +
setDrawerOpen(false)}> +
e.stopPropagation()}> + {/* Drawer Header */} +
+
+ +

Price Target Alarms

+ + {activeAlerts.length} / 10 Active + +
+ +
+ + +
+
+ + {/* Drawer Subheader Action */} +
+ +
+ + {/* Alerts List */} +
+ {isLoading && alerts.length === 0 ? ( +
Loading active price alarms...
+ ) : alerts.length === 0 ? ( +
+ +

No Price Alarms Set

+

+ Set target price alarms for your stocks to receive instant Web and Email notifications. +

+
+ ) : ( +
+ {/* Active Alarms Section */} + {activeAlerts.length > 0 && ( +
+

Active Alarms ({activeAlerts.length})

+ {activeAlerts.map((alert) => ( +
+
+
+ {alert.symbol.substring(0, 2)} +
+
+
+ {alert.symbol} + + {alert.condition === 'ABOVE' ? ( + + ) : ( + + )} + {alert.condition} ${alert.target_price.toFixed(2)} + +
+ Set {formatDate(alert.created_at)} +
+
+ + +
+ ))} +
+ )} + + {/* Triggered Alarms Section */} + {triggeredAlerts.length > 0 && ( +
+

Triggered History ({triggeredAlerts.length})

+ {triggeredAlerts.map((alert) => ( +
+
+
🔔
+
+
+ {alert.symbol} + + Triggered @ ${alert.target_price.toFixed(2)} + +
+ + Triggered {formatDate(alert.triggered_at)} + +
+
+ + +
+ ))} +
+ )} +
+ )} +
+
+
+ ); +}; diff --git a/src/components/PortfolioView.tsx b/src/components/PortfolioView.tsx index 0d3c4c9..b7f3cb8 100644 --- a/src/components/PortfolioView.tsx +++ b/src/components/PortfolioView.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, useTransition } from 'react'; +import { Bell } from 'lucide-react'; import { portfolioService } from '@/services/portfolio.service'; import type { Portfolio } from '@/services/portfolio.service'; +import { usePriceAlertStore } from '@/stores/priceAlertStore'; import '@/styles/components/portfolio.css'; interface PortfolioViewProps { @@ -14,6 +16,7 @@ export const PortfolioView: React.FC = ({ onSelectStock }) = const [newSymbol, setNewSymbol] = useState(''); const [error, setError] = useState(null); const [, startTransition] = useTransition(); + const openModal = usePriceAlertStore((state) => state.openModal); useEffect(() => { fetchPortfolio(); @@ -270,15 +273,39 @@ export const PortfolioView: React.FC = ({ onSelectStock }) = )} - +
+ + + +
)) )} diff --git a/src/components/PriceAlertModal.tsx b/src/components/PriceAlertModal.tsx new file mode 100644 index 0000000..6a19ed0 --- /dev/null +++ b/src/components/PriceAlertModal.tsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect } from 'react'; +import { X, Bell, TrendingUp, TrendingDown, AlertCircle } from 'lucide-react'; +import { usePriceAlertStore } from '@/stores/priceAlertStore'; +import type { AlertCondition } from '@/services/priceAlert.service'; +import '@/styles/components/price-alerts.css'; + +export const PriceAlertModal: React.FC = () => { + const { isModalOpen, targetSymbol, initialPrice, closeModal, addAlert, error, isLoading } = + usePriceAlertStore(); + + const [symbol, setSymbol] = useState(''); + const [targetPrice, setTargetPrice] = useState(''); + const [condition, setCondition] = useState('ABOVE'); + const [formError, setFormError] = useState(null); + + useEffect(() => { + if (isModalOpen) { + setSymbol(targetSymbol || ''); + setCondition('ABOVE'); + setFormError(null); + if (initialPrice && initialPrice > 0) { + setTargetPrice(initialPrice.toString()); + } else { + setTargetPrice(''); + } + } + }, [isModalOpen, targetSymbol, initialPrice]); + + if (!isModalOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + + const cleanSymbol = symbol.trim().toUpperCase(); + const priceNum = parseFloat(targetPrice); + + if (!cleanSymbol) { + setFormError('Stock symbol is required.'); + return; + } + + if (isNaN(priceNum) || priceNum <= 0) { + setFormError('Please enter a valid target price greater than $0.00.'); + return; + } + + try { + await addAlert(cleanSymbol, priceNum, condition); + } catch (err: any) { + // Error handles in Zustand store state + } + }; + + const adjustPriceByPercent = (percent: number) => { + const current = parseFloat(targetPrice) || initialPrice || 100; + const newPrice = current * (1 + percent / 100); + setTargetPrice(newPrice.toFixed(2)); + }; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+
+ +
+
+

Set Price Target Alarm

+

+ Get notified when the stock hits your target threshold. +

+
+
+ +
+ + {/* Error Alert */} + {(error || formError) && ( +
+ + {formError || error} +
+ )} + + {/* Form */} +
+ {/* Symbol Input */} +
+ + setSymbol(e.target.value.toUpperCase())} + maxLength={10} + required + /> +
+ + {/* Condition Selector Pills */} +
+ +
+ + + +
+
+ + {/* Target Price Input */} +
+
+ + {initialPrice && initialPrice > 0 && ( + + Current: ${initialPrice.toFixed(2)} + + )} +
+
+ $ + setTargetPrice(e.target.value)} + required + /> +
+ + {/* Quick Adjust Percentage Buttons */} +
+ + + + +
+
+ + {/* Footer Actions */} +
+ + +
+
+
+
+ ); +}; diff --git a/src/components/TradingViewChart.tsx b/src/components/TradingViewChart.tsx index fe11139..4861c1c 100644 --- a/src/components/TradingViewChart.tsx +++ b/src/components/TradingViewChart.tsx @@ -1,7 +1,9 @@ import React, { useEffect, useRef, useState } from 'react'; import { createChart, CandlestickSeries } from 'lightweight-charts'; import type { IChartApi } from 'lightweight-charts'; +import { Bell } from 'lucide-react'; import { marketService } from '@/services/market.service'; +import { usePriceAlertStore } from '@/stores/priceAlertStore'; interface TradingViewChartProps { symbol: string | null; @@ -13,6 +15,7 @@ export const TradingViewChart: React.FC = ({ symbol }) => const [analysisData, setAnalysisData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const openModal = usePriceAlertStore((state) => state.openModal); useEffect(() => { if (!symbol || !chartContainerRef.current) return; @@ -32,7 +35,7 @@ export const TradingViewChart: React.FC = ({ symbol }) => chartRef.current = null; } - if (response.chart_data && response.chart_data.length > 0) { + if (chartContainerRef.current && response.chart_data && response.chart_data.length > 0) { // Initialize TradingView Lightweight Chart const chart = createChart(chartContainerRef.current, { layout: { @@ -90,9 +93,32 @@ export const TradingViewChart: React.FC = ({ symbol }) => if (!symbol) return null; return ( -
+
-

{symbol} Chart Analysis

+
+

{symbol} Chart Analysis

+ {symbol && ( + + )} +
{analysisData && (
diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index 98f134f..576bfe7 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -1,8 +1,10 @@ import React, { useState } from 'react'; +import { Bell } from 'lucide-react'; import type { UserResponse } from '@/features/auth/auth.types'; import { PortfolioView } from '@/components/PortfolioView'; import { Sidebar } from '@/components/Sidebar'; import { TradingViewChart } from '@/components/TradingViewChart'; +import { usePriceAlertStore } from '@/stores/priceAlertStore'; import '@/styles/components/dashboard.css'; interface DashboardPageProps { @@ -15,6 +17,8 @@ interface DashboardPageProps { */ export const DashboardPage: React.FC = ({ user, onLogout }) => { const [selectedStock, setSelectedStock] = useState(null); + const { toggleDrawer, alerts } = usePriceAlertStore(); + const activeAlertsCount = alerts.filter((a) => !a.is_triggered).length; return (
= ({ user, onLogout }) {/* Main Content Area */}
-

- Welcome back, {user?.username || 'Trader'}! -

-

- Monitor and manage your active stock portfolios in real-time. -

+
+
+

+ Welcome back, {user?.username || 'Trader'}! +

+

+ Monitor and manage your active stock portfolios in real-time. +

+
+ + +
diff --git a/src/pages/LiveMarketDashboard.tsx b/src/pages/LiveMarketDashboard.tsx index 7d01f55..ee92801 100644 --- a/src/pages/LiveMarketDashboard.tsx +++ b/src/pages/LiveMarketDashboard.tsx @@ -10,14 +10,37 @@ import { Check, TrendingUp, TrendingDown, + Bell, } from 'lucide-react'; import { useUserStore } from '@/stores/userStore'; +import { usePriceAlertStore } from '@/stores/priceAlertStore'; import { portfolioService } from '@/services/portfolio.service'; import { Sidebar } from '@/components/Sidebar'; import { StockSearchBar } from '@/components/StockSearchBar'; +import { PriceAlertModal } from '@/components/PriceAlertModal'; +import { ActiveAlertsDrawer } from '@/components/ActiveAlertsDrawer'; import { ROUTES } from '@/constants/routes.constants'; import '@/styles/components/live-market.css'; +const playAlertChime = () => { + try { + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(880, audioCtx.currentTime); + osc.frequency.exponentialRampToValueAtTime(1760, audioCtx.currentTime + 0.3); + gain.gain.setValueAtTime(0.15, audioCtx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.35); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(); + osc.stop(audioCtx.currentTime + 0.35); + } catch (e) { + console.error('Audio chime error:', e); + } +}; + const MARKET_WS_URL = import.meta.env.VITE_MARKET_WS_BASE_URL || 'ws://localhost:8001'; interface MarketStock { @@ -41,12 +64,21 @@ export const LiveMarketDashboard: React.FC = () => { ); const { user, logout, isAuthenticated } = useUserStore(); + const { openModal, toggleDrawer, fetchAlerts, alerts } = usePriceAlertStore(); + const activeAlertsCount = alerts.filter((a) => !a.is_triggered).length; const handleLogout = async () => { await logout(); navigate(ROUTES.HOME); }; + // Fetch initial user alerts when authenticated + useEffect(() => { + if (isAuthenticated) { + fetchAlerts(); + } + }, [isAuthenticated, fetchAlerts]); + // 1. Listen to market data WebSocket useEffect(() => { let ws: WebSocket; @@ -68,7 +100,14 @@ export const LiveMarketDashboard: React.FC = () => { ws.onmessage = (event) => { try { const liveData = JSON.parse(event.data); - if (liveData && Array.isArray(liveData.gainers) && Array.isArray(liveData.losers)) { + if (liveData && liveData.type === 'PRICE_ALERT_TRIGGERED') { + playAlertChime(); + setFeedback({ + message: `🔔 PRICE ALERT: ${liveData.symbol} hit target price $${liveData.target_price}!`, + type: 'success', + }); + fetchAlerts(); + } else if (liveData && Array.isArray(liveData.gainers) && Array.isArray(liveData.losers)) { setMarketData(liveData); } } catch (err) { @@ -209,6 +248,15 @@ export const LiveMarketDashboard: React.FC = () => { placeholder="Search stocks or tickers..." />
+ @@ -227,13 +275,22 @@ export const LiveMarketDashboard: React.FC = () => { {/* Top Alerts Card */}
-

Get alerted for anomalies

+

Smart Price Target Alerts

- Automatically monitor market volume and price anomalies and get notified instantly. + Monitor market thresholds and receive instant Web Audio & Resend email alarms.

- +
+ + +
@@ -313,21 +370,33 @@ export const LiveMarketDashboard: React.FC = () => {
- +
+ + + +
); }) @@ -377,21 +446,33 @@ export const LiveMarketDashboard: React.FC = () => {
- +
+ + + +
); }) @@ -403,6 +484,10 @@ export const LiveMarketDashboard: React.FC = () => { )}
+ + {/* Price Alert Modal & Active Alerts Drawer */} + + ); }; diff --git a/src/services/priceAlert.service.ts b/src/services/priceAlert.service.ts new file mode 100644 index 0000000..47647fa --- /dev/null +++ b/src/services/priceAlert.service.ts @@ -0,0 +1,54 @@ +import { marketApiClient } from '@/lib/api.client'; + +export type AlertCondition = 'ABOVE' | 'BELOW'; + +export interface PriceAlertItem { + id: string; + user_id: string; + symbol: string; + target_price: number; + condition: AlertCondition; + is_triggered: boolean; + created_at: string; + triggered_at?: string | null; +} + +export interface PriceAlertListResponse { + items: PriceAlertItem[]; + total: number; +} + +export interface CreatePriceAlertPayload { + symbol: string; + target_price: number; + condition: AlertCondition; +} + +export const priceAlertService = { + /** + * Create a new price target alert for a stock symbol + */ + async createAlert(payload: CreatePriceAlertPayload): Promise { + const response = await marketApiClient.post('/portfolio/alerts', { + symbol: payload.symbol.trim().toUpperCase(), + target_price: payload.target_price, + condition: payload.condition, + }); + return response.data; + }, + + /** + * Get all active and triggered price alerts for the current user + */ + async getUserAlerts(): Promise { + const response = await marketApiClient.get('/portfolio/alerts'); + return response.data; + }, + + /** + * Delete an existing price alert by ID + */ + async deleteAlert(alertId: string): Promise { + await marketApiClient.delete(`/portfolio/alerts/${alertId}`); + }, +}; diff --git a/src/stores/priceAlertStore.ts b/src/stores/priceAlertStore.ts new file mode 100644 index 0000000..96dc972 --- /dev/null +++ b/src/stores/priceAlertStore.ts @@ -0,0 +1,110 @@ +import { create } from 'zustand'; +import { + priceAlertService, + type PriceAlertItem, + type AlertCondition, +} from '@/services/priceAlert.service'; + +interface PriceAlertState { + alerts: PriceAlertItem[]; + isLoading: boolean; + error: string | null; + + // Modal State + isModalOpen: boolean; + targetSymbol: string; + initialPrice: number | null; + + // Drawer / List Panel State + isDrawerOpen: boolean; + + // Actions + openModal: (symbol?: string, currentPrice?: number | null) => void; + closeModal: () => void; + setDrawerOpen: (open: boolean) => void; + toggleDrawer: () => void; + + fetchAlerts: () => Promise; + addAlert: (symbol: string, targetPrice: number, condition: AlertCondition) => Promise; + removeAlert: (id: string) => Promise; +} + +export const usePriceAlertStore = create((set, get) => ({ + alerts: [], + isLoading: false, + error: null, + + isModalOpen: false, + targetSymbol: '', + initialPrice: null, + isDrawerOpen: false, + + openModal: (symbol = '', currentPrice = null) => { + set({ + isModalOpen: true, + targetSymbol: symbol.toUpperCase(), + initialPrice: currentPrice, + error: null, + }); + }, + + closeModal: () => { + set({ isModalOpen: false, targetSymbol: '', initialPrice: null, error: null }); + }, + + setDrawerOpen: (open: boolean) => { + set({ isDrawerOpen: open }); + }, + + toggleDrawer: () => { + set({ isDrawerOpen: !get().isDrawerOpen }); + }, + + fetchAlerts: async () => { + set({ isLoading: true, error: null }); + try { + const data = await priceAlertService.getUserAlerts(); + set({ alerts: data.items, isLoading: false }); + } catch (err: any) { + const msg = err.response?.data?.detail || 'Failed to fetch price alerts.'; + set({ error: msg, isLoading: false }); + } + }, + + addAlert: async (symbol: string, targetPrice: number, condition: AlertCondition) => { + set({ isLoading: true, error: null }); + try { + const newAlert = await priceAlertService.createAlert({ + symbol, + target_price: targetPrice, + condition, + }); + set((state) => ({ + alerts: [newAlert, ...state.alerts], + isLoading: false, + isModalOpen: false, + })); + return newAlert; + } catch (err: any) { + const errorMsg = + typeof err.response?.data?.detail === 'string' + ? err.response.data.detail + : err.message || 'Failed to create price target alert.'; + set({ error: errorMsg, isLoading: false }); + throw new Error(errorMsg); + } + }, + + removeAlert: async (id: string) => { + try { + await priceAlertService.deleteAlert(id); + set((state) => ({ + alerts: state.alerts.filter((a) => a.id !== id), + })); + } catch (err: any) { + const errorMsg = err.response?.data?.detail || 'Failed to delete alert.'; + set({ error: errorMsg }); + throw new Error(errorMsg); + } + }, +})); diff --git a/src/styles/components/price-alerts.css b/src/styles/components/price-alerts.css new file mode 100644 index 0000000..8460666 --- /dev/null +++ b/src/styles/components/price-alerts.css @@ -0,0 +1,535 @@ +/* Vercel Dark Mode Price Target Alerts System Styles */ + +/* Backdrop Overlay */ +.vercel-modal-backdrop, +.vercel-drawer-backdrop { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.15s ease-out; +} + +.vercel-drawer-backdrop { + justify-content: flex-end; +} + +/* Modal Content Card */ +.vercel-modal-content { + background-color: #0a0a0a; + border: 1px solid #1a1a1a; + border-radius: 12px; + width: 100%; + max-width: 480px; + padding: 24px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.8); + animation: scaleUp 0.15s ease-out; +} + +/* Drawer Content Panel */ +.vercel-drawer-content { + background-color: #0a0a0a; + border-left: 1px solid #1a1a1a; + width: 100%; + max-width: 420px; + height: 100vh; + display: flex; + flex-direction: column; + box-shadow: -10px 0 30px rgba(0, 0, 0, 0.8); + animation: slideLeft 0.2s ease-out; +} + +/* Header Sections */ +.vercel-modal-header, +.vercel-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 16px; + border-bottom: 1px solid #1a1a1a; +} + +.vercel-drawer-header { + padding: 20px 24px; +} + +.vercel-modal-title-group, +.vercel-drawer-title-group { + display: flex; + align-items: center; + gap: 12px; +} + +.vercel-modal-icon { + width: 36px; + height: 36px; + border-radius: 8px; + background: rgba(0, 229, 153, 0.1); + border: 1px solid rgba(0, 229, 153, 0.25); + display: flex; + align-items: center; + justify-content: center; +} + +.vercel-modal-title, +.vercel-drawer-title { + font-size: 16px; + font-weight: 700; + color: #ffffff; + margin: 0; + letter-spacing: -0.01em; +} + +.vercel-modal-sub { + font-size: 12px; + color: #888888; + margin: 2px 0 0 0; +} + +.vercel-drawer-count-badge { + font-size: 11px; + font-weight: 600; + background: #111111; + border: 1px solid #222222; + color: #00e599; + padding: 2px 8px; + border-radius: 12px; +} + +.vercel-modal-close-btn, +.vercel-drawer-close-btn, +.vercel-icon-btn { + background: transparent; + border: 1px solid #222222; + color: #888888; + width: 32px; + height: 32px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.vercel-modal-close-btn:hover, +.vercel-drawer-close-btn:hover, +.vercel-icon-btn:hover { + background: #1a1a1a; + color: #ffffff; + border-color: #333333; +} + +/* Form Styles */ +.vercel-alert-form { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 20px; +} + +.vercel-form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.vercel-label-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.vercel-form-label { + font-size: 12px; + font-weight: 600; + color: #888888; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.vercel-current-price-tag { + font-size: 12px; + color: #00e599; + font-weight: 600; +} + +.vercel-form-input { + width: 100%; + background: #111111; + border: 1px solid #222222; + border-radius: 6px; + padding: 10px 14px; + color: #ffffff; + font-size: 14px; + outline: none; + transition: border-color 0.15s ease; +} + +.vercel-form-input:focus { + border-color: #00e599; +} + +.vercel-form-input.uppercase { + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.05em; + font-family: monospace; +} + +/* Price Currency Input */ +.vercel-price-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.vercel-currency-symbol { + position: absolute; + left: 14px; + color: #666666; + font-weight: 600; + font-size: 15px; +} + +.vercel-form-input.price { + padding-left: 28px; + font-size: 18px; + font-weight: 700; +} + +/* Condition Pills */ +.vercel-condition-pills { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.vercel-pill-btn { + background: #111111; + border: 1px solid #222222; + border-radius: 6px; + padding: 10px; + color: #666666; + font-size: 11px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.15s ease; +} + +.vercel-pill-btn:hover { + border-color: #333333; + color: #888888; +} + +.vercel-pill-btn.active.above { + background: rgba(0, 229, 153, 0.1); + border-color: #00e599; + color: #00e599; +} + +.vercel-pill-btn.active.below { + background: rgba(255, 68, 68, 0.1); + border-color: #ff4444; + color: #ff4444; +} + +/* Quick Adjust Buttons */ +.vercel-quick-adjust-row { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.vercel-quick-btn { + flex: 1; + background: #141414; + border: 1px solid #222222; + border-radius: 4px; + padding: 4px 0; + color: #888888; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.vercel-quick-btn:hover { + background: #1a1a1a; + color: #ffffff; + border-color: #333333; +} + +/* Alert Banner */ +.vercel-alert-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + margin-top: 12px; +} + +.vercel-alert-banner.error { + background: rgba(255, 68, 68, 0.12); + border: 1px solid rgba(255, 68, 68, 0.3); + color: #ff4444; +} + +/* Footer Actions */ +.vercel-modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 12px; + padding-top: 16px; + border-top: 1px solid #1a1a1a; +} + +.vercel-btn-cancel { + background: transparent; + border: 1px solid #222222; + border-radius: 6px; + padding: 10px 18px; + color: #888888; + font-weight: 600; + font-size: 13px; + cursor: pointer; + transition: all 0.15s ease; +} + +.vercel-btn-cancel:hover { + background: #141414; + color: #ffffff; +} + +.vercel-btn-submit { + background: #00e599; + border: none; + border-radius: 6px; + padding: 10px 20px; + color: #000000; + font-weight: 700; + font-size: 13px; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0 0 16px rgba(0, 229, 153, 0.2); +} + +.vercel-btn-submit:hover { + background: #00cc88; + box-shadow: 0 0 20px rgba(0, 229, 153, 0.35); +} + +.vercel-btn-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Drawer Body & Items */ +.vercel-drawer-subbar { + padding: 16px 24px; + border-bottom: 1px solid #1a1a1a; +} + +.vercel-btn-create-alert { + width: 100%; + background: #ffffff; + border: none; + border-radius: 6px; + padding: 10px; + color: #000000; + font-weight: 700; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.15s ease; +} + +.vercel-btn-create-alert:hover { + background: #e6e6e6; +} + +.vercel-drawer-body { + flex: 1; + overflow-y: auto; + padding: 20px 24px; +} + +.vercel-drawer-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 240px; + text-align: center; +} + +.vercel-alerts-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.vercel-group-label { + font-size: 11px; + font-weight: 700; + color: #666666; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 12px 0; +} + +.vercel-alert-item { + display: flex; + align-items: center; + justify-content: space-between; + background: #111111; + border: 1px solid #1a1a1a; + border-radius: 8px; + padding: 12px 14px; + transition: all 0.15s ease; +} + +.vercel-alert-item:hover { + border-color: #262626; + background: #141414; +} + +.vercel-alert-item.triggered { + border-color: rgba(255, 170, 0, 0.3); + background: rgba(255, 170, 0, 0.04); +} + +.vercel-item-left { + display: flex; + align-items: center; + gap: 12px; +} + +.vercel-symbol-avatar { + width: 32px; + height: 32px; + border-radius: 6px; + background: #1a1a1a; + border: 1px solid #262626; + color: #ffffff; + font-weight: 800; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; +} + +.vercel-symbol-avatar.triggered { + background: rgba(255, 170, 0, 0.15); + border-color: rgba(255, 170, 0, 0.4); +} + +.vercel-symbol-name { + font-size: 14px; + font-weight: 800; + color: #ffffff; + font-family: monospace; +} + +.vercel-condition-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: 10px; +} + +.vercel-condition-badge.above { + background: rgba(0, 229, 153, 0.1); + color: #00e599; + border: 1px solid rgba(0, 229, 153, 0.25); +} + +.vercel-condition-badge.below { + background: rgba(255, 68, 68, 0.1); + color: #ff4444; + border: 1px solid rgba(255, 68, 68, 0.25); +} + +.vercel-triggered-tag { + font-size: 11px; + font-weight: 700; + color: #ffaa00; +} + +.vercel-alert-time { + font-size: 11px; + color: #666666; +} + +.vercel-alert-delete-btn { + background: transparent; + border: none; + color: #666666; + cursor: pointer; + padding: 6px; + border-radius: 4px; + transition: all 0.15s ease; +} + +.vercel-alert-delete-btn:hover { + color: #ff4444; + background: rgba(255, 68, 68, 0.1); +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes scaleUp { + from { + transform: scale(0.95); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +@keyframes slideLeft { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +}