diff --git a/backend/src/controllers/market.ts b/backend/src/controllers/market.ts
index 701b495..6e8d17b 100644
--- a/backend/src/controllers/market.ts
+++ b/backend/src/controllers/market.ts
@@ -16,10 +16,10 @@ import { marketStore } from "../store/market";
import { sendApiError, sendEngineError } from "../utils/apiError";
const intervalMap = {
- "1M": "1 minute",
- "5M": "5 minutes",
- "15M": "15 minutes",
- "30M": "30 minutes",
+ "1m": "1 minute",
+ "5m": "5 minutes",
+ "15m": "15 minutes",
+ "30m": "30 minutes",
"1H": "1 hour",
"4H": "4 hours",
"1D": "1 day",
@@ -107,7 +107,7 @@ export async function getCandles(req: Request, res: Response) {
}
const { symbol } = parsedParams.data;
- const { interval = "15M" } = parsedQueries.data;
+ const { interval = "15m" } = parsedQueries.data;
const bucket = intervalMap[interval];
if (!marketStore.has(symbol)) {
sendApiError(res, 404, "MARKET_NOT_FOUND", `Market ${symbol} was not found`);
diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts
index e21e4d6..b88ca03 100644
--- a/backend/src/controllers/user.ts
+++ b/backend/src/controllers/user.ts
@@ -11,6 +11,10 @@ import { getReferencePrices } from "../utils/referencePrice";
import { config } from "../config";
import { ApiError, sendApiError, sendEngineError } from "../utils/apiError";
import { claimDailyCredit } from "../services/dailyCredit";
+import { updateEmailSchema } from "../schema/auth";
+import { isEmailDeliveryEnabled, sendVerificationEmail } from "../utils/emailClient";
+import { Prisma } from "../../generated/prisma/client";
+import crypto from "crypto";
export function getUserId(req: Request): string {
const userId = req.principal?.userId;
@@ -50,6 +54,81 @@ export async function getUserData(req: Request, res: Response) {
}
}
+export async function updateUserEmail(req: Request, res: Response) {
+ const parsedBody = updateEmailSchema.safeParse(req.body);
+
+ if (!parsedBody.success) {
+ sendValidationError(res, parsedBody.error);
+ return;
+ }
+
+ const userId = getUserId(req);
+ const { email } = parsedBody.data;
+
+ if (!isEmailDeliveryEnabled()) {
+ sendApiError(res, 503, "SERVICE_UNAVAILABLE", "Email delivery is currently unavailable");
+ return;
+ }
+
+ try {
+ const user = await prisma.user.findUnique({ where: { id: userId } });
+
+ if (!user) {
+ res.clearCookie("token", config.cookie);
+ sendApiError(res, 401, "AUTHENTICATION_REQUIRED", "Authentication required");
+ return;
+ }
+
+ if (user.emailVerified) {
+ sendApiError(res, 409, "EMAIL_ALREADY_VERIFIED", "Verified email cannot be changed here");
+ return;
+ }
+
+ if (user.email === email) {
+ sendApiError(res, 409, "EMAIL_UNCHANGED", "Enter a different email address");
+ return;
+ }
+
+ const existingUser = await prisma.user.findUnique({ where: { email } });
+ if (existingUser) {
+ sendApiError(res, 409, "EMAIL_IN_USE", "An account with this email already exists");
+ return;
+ }
+
+ const token = crypto.randomBytes(32).toString("hex");
+ const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
+ const updatedUser = await prisma.$transaction(async (tx) => {
+ await tx.verificationToken.deleteMany({ where: { userId } });
+ const updated = await tx.user.update({ where: { id: userId }, data: { email } });
+ await tx.verificationToken.create({
+ data: {
+ userId,
+ tokenHash,
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
+ },
+ });
+ return updated;
+ });
+
+ void sendVerificationEmail(updatedUser.name, updatedUser.email, token);
+
+ res.status(200).json({
+ id: updatedUser.id,
+ name: updatedUser.name,
+ email: updatedUser.email,
+ emailVerified: updatedUser.emailVerified,
+ message: "Email updated. Check your inbox for a new verification link.",
+ });
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
+ sendApiError(res, 409, "EMAIL_IN_USE", "An account with this email already exists");
+ return;
+ }
+ logger.error("Email update failed", error);
+ sendApiError(res, 500, "INTERNAL_ERROR", "Email could not be updated");
+ }
+}
+
export async function getTradeHistory(req: Request, res: Response) {
const userId = getUserId(req);
const parsedQueries = tradeHistoryQuerySchema.safeParse(req.query);
diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts
index 6feab5e..f65a4d6 100644
--- a/backend/src/routes/user.ts
+++ b/backend/src/routes/user.ts
@@ -7,8 +7,10 @@ import {
getBalance,
getPortfolio,
getUserData,
+ updateUserEmail,
} from "../controllers/user";
import { getTradeHistory } from "../controllers/user";
+import { authLimiter } from "../middleware/rateLimit";
export const userRouter = Router();
@@ -17,6 +19,12 @@ userRouter.get(
requireAccess({ types: ["session"], allowUnverified: true }),
asyncHandler(getUserData),
);
+userRouter.patch(
+ "/users/me/email",
+ authLimiter,
+ requireAccess({ types: ["session"], allowUnverified: true }),
+ asyncHandler(updateUserEmail),
+);
userRouter.get(
"/trades",
requireAccess({ scopes: ["ORDER_READ"], allowUnverified: true }),
diff --git a/backend/src/schema/auth.ts b/backend/src/schema/auth.ts
index 2776662..97b060d 100644
--- a/backend/src/schema/auth.ts
+++ b/backend/src/schema/auth.ts
@@ -18,3 +18,7 @@ export const verifyEmailSchema = z.object({
export const resendVerificationEmailSchema = z.object({
email: z.email().trim().min(1, "email is required"),
});
+
+export const updateEmailSchema = z.object({
+ email: z.email().trim().min(1, "email is required"),
+});
diff --git a/backend/src/schema/exchange.ts b/backend/src/schema/exchange.ts
index 051ad21..9d8e72c 100644
--- a/backend/src/schema/exchange.ts
+++ b/backend/src/schema/exchange.ts
@@ -34,7 +34,7 @@ export const orderBodySchema = z.discriminatedUnion("type", [
]);
export const candleQuerySchema = z.object({
- interval: z.enum(["1M", "5M", "15M", "30M", "1H", "4H", "1D"]).optional(),
+ interval: z.enum(["1m", "5m", "15m", "30m", "1H", "4H", "1D"]).optional(),
});
export const orderQuerySchema = z.object({
diff --git a/backend/tests/schemas.test.ts b/backend/tests/schemas.test.ts
index 40be101..666940b 100644
--- a/backend/tests/schemas.test.ts
+++ b/backend/tests/schemas.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
-import { signinSchema, signupSchema } from "../src/schema/auth";
+import { signinSchema, signupSchema, updateEmailSchema } from "../src/schema/auth";
import { orderBodySchema, orderQuerySchema } from "../src/schema/exchange";
import { leaderboardQuerySchema } from "../src/schema/leaderboard";
@@ -18,6 +18,11 @@ test("account credentials reject malformed or missing identity fields", () => {
expect(signinSchema.safeParse({ email: "alice@test.com", password: "" }).success).toBe(false);
});
+test("email correction accepts only a valid email", () => {
+ expect(updateEmailSchema.safeParse({ email: "correct@example.com" }).success).toBe(true);
+ expect(updateEmailSchema.safeParse({ email: "wrong-email" }).success).toBe(false);
+});
+
test("leaderboard pagination applies public query limits", () => {
expect(leaderboardQuerySchema.safeParse({ limit: "25", offset: "0" }).success).toBe(true);
expect(leaderboardQuerySchema.safeParse({ limit: "101" }).success).toBe(false);
diff --git a/frontend/src/components/chart/chart-toolbar.tsx b/frontend/src/components/chart/chart-toolbar.tsx
index 7fe2a32..3fd539f 100644
--- a/frontend/src/components/chart/chart-toolbar.tsx
+++ b/frontend/src/components/chart/chart-toolbar.tsx
@@ -1,4 +1,13 @@
-import { ChartNoAxesColumn, ChevronDown, LocateFixed, Maximize2, RotateCcw } from "lucide-react";
+import {
+ ChartArea,
+ ChartCandlestick,
+ ChartLine,
+ ChartNoAxesColumn,
+ ChevronDown,
+ LocateFixed,
+ Maximize2,
+ RotateCcw,
+} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -8,11 +17,20 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CANDLE_INTERVALS, type CandleInterval } from "../../hooks/use-candles";
+import type { ChartStyle } from "./chart-utils";
+
+const CHART_STYLES = [
+ { value: "candlestick", label: "Candle", Icon: ChartCandlestick },
+ { value: "line", label: "Line", Icon: ChartLine },
+ { value: "area", label: "Area", Icon: ChartArea },
+] as const;
type ChartToolbarProps = {
interval: CandleInterval;
+ chartStyle: ChartStyle;
showVolume: boolean;
onIntervalChange: (interval: CandleInterval) => void;
+ onChartStyleChange: (style: ChartStyle) => void;
onToggleVolume: () => void;
onGoLive: () => void;
onReset: () => void;
@@ -21,24 +39,35 @@ type ChartToolbarProps = {
export function ChartToolbar({
interval,
+ chartStyle,
showVolume,
onIntervalChange,
+ onChartStyleChange,
onToggleVolume,
onGoLive,
onReset,
onFullscreen,
}: ChartToolbarProps) {
+ const activeChartStyle =
+ CHART_STYLES.find((option) => option.value === chartStyle) ?? CHART_STYLES[0];
+ const ActiveChartIcon = activeChartStyle.Icon;
+
return (
-
+
{interval}
-
+
onIntervalChange(value as CandleInterval)}
@@ -54,6 +83,38 @@ export function ChartToolbar({
+
+
+
+
+
+ {activeChartStyle.label}
+
+
+
+
+
+ onChartStyleChange(value as ChartStyle)}
+ >
+ {CHART_STYLES.map(({ value, label, Icon }) => (
+
+
+ {label}
+
+ ))}
+
+
+
+
+
+
= {
All: null,
@@ -21,6 +22,9 @@ export function getChartColors() {
text: token("--muted-foreground", "#8e8e93"),
grid: token("--border", "#1e1e24"),
crosshair: token("--chart-crosshair", "#3e3e48"),
+ line: token("--chart-line", "#3f6db5"),
+ lineFillTop: token("--chart-line-fill-top", "rgba(63, 109, 181, 0.14)"),
+ lineFillBottom: token("--chart-line-fill-bottom", "rgba(63, 109, 181, 0)"),
up: token("--green-text", "#00c087"),
down: token("--red-text", "#ff3b30"),
volumeUp: token("--green-bg", "#1a3a2a"),
diff --git a/frontend/src/components/chart/chart.tsx b/frontend/src/components/chart/chart.tsx
index 5b00c1c..02974f9 100644
--- a/frontend/src/components/chart/chart.tsx
+++ b/frontend/src/components/chart/chart.tsx
@@ -5,7 +5,7 @@ import { type CandleInterval, useCandles } from "../../hooks/use-candles";
import { useMarket } from "@/context/MarketContext";
import { cn } from "@/lib/utils";
import { ChartToolbar } from "./chart-toolbar";
-import { CHART_RANGES, type ChartRange } from "./chart-utils";
+import { CHART_RANGES, type ChartRange, type ChartStyle } from "./chart-utils";
import { DepthChart } from "./depth-chart";
import { MarketInfo } from "./market-info";
import { PriceChart } from "./price-chart";
@@ -53,6 +53,7 @@ export function Chart({ symbol, orderbook, ticker }: ChartProps) {
const panelRef = useRef(null);
const [tab, setTab] = useState("chart");
const [interval, setInterval] = useState("1H");
+ const [chartStyle, setChartStyle] = useState("candlestick");
const [range, setRange] = useState("All");
const [showVolume, setShowVolume] = useState(true);
const [hoveredCandle, setHoveredCandle] = useState(null);
@@ -107,8 +108,10 @@ export function Chart({ symbol, orderbook, ticker }: ChartProps) {
<>
setShowVolume((visible) => !visible)}
onGoLive={() => setGoLiveKey((key) => key + 1)}
onReset={resetChart}
@@ -182,6 +185,7 @@ export function Chart({ symbol, orderbook, ticker }: ChartProps) {
) : (
(null);
const chartRef = useRef(null);
const candleSeriesRef = useRef | null>(null);
+ const lineSeriesRef = useRef | null>(null);
+ const areaSeriesRef = useRef | null>(null);
const volumeSeriesRef = useRef | null>(null);
const previousCandlesRef = useRef([]);
const candlesRef = useRef(candles);
@@ -134,20 +149,48 @@ export function PriceChart({
},
localization: { timeFormatter: formatChartTime },
});
- const candleSeries = chart.addSeries(CandlestickSeries, {
- upColor: colors.up,
- downColor: colors.down,
- borderVisible: false,
- wickUpColor: colors.up,
- wickDownColor: colors.down,
- priceFormat: {
- type: "price",
- precision: pricePrecision,
- minMove: 10 ** -pricePrecision,
- },
- priceLineVisible: true,
- priceLineStyle: LineStyle.Dotted,
- });
+ const priceFormat = {
+ type: "price" as const,
+ precision: pricePrecision,
+ minMove: 10 ** -pricePrecision,
+ };
+ const currentCandles = candlesRef.current;
+ if (chartStyle === "candlestick") {
+ const candleSeries = chart.addSeries(CandlestickSeries, {
+ upColor: colors.up,
+ downColor: colors.down,
+ borderVisible: false,
+ wickUpColor: colors.up,
+ wickDownColor: colors.down,
+ priceFormat,
+ priceLineVisible: true,
+ priceLineStyle: LineStyle.Dotted,
+ });
+ candleSeries.setData(currentCandles.map(candleData));
+ candleSeriesRef.current = candleSeries;
+ } else if (chartStyle === "line") {
+ const lineSeries = chart.addSeries(LineSeries, {
+ color: colors.line,
+ lineWidth: 2,
+ priceFormat,
+ priceLineVisible: true,
+ priceLineStyle: LineStyle.Dotted,
+ });
+ lineSeries.setData(currentCandles.map(lineData));
+ lineSeriesRef.current = lineSeries;
+ } else {
+ const areaSeries = chart.addSeries(AreaSeries, {
+ lineColor: colors.line,
+ topColor: colors.lineFillTop,
+ bottomColor: colors.lineFillBottom,
+ lineWidth: 2,
+ priceFormat,
+ priceLineVisible: true,
+ priceLineStyle: LineStyle.Dotted,
+ });
+ areaSeries.setData(currentCandles.map(lineData));
+ areaSeriesRef.current = areaSeries;
+ }
const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: "volume" },
priceScaleId: "volume",
@@ -155,8 +198,6 @@ export function PriceChart({
});
volumeSeries.priceScale().applyOptions({ scaleMargins: { top: 0.83, bottom: 0 } });
- const currentCandles = candlesRef.current;
- candleSeries.setData(currentCandles.map(candleData));
volumeSeries.setData(
currentCandles.map((candle) => volumeData(candle, colors.volumeUp, colors.volumeDown)),
);
@@ -173,7 +214,6 @@ export function PriceChart({
});
chartRef.current = chart;
- candleSeriesRef.current = candleSeries;
volumeSeriesRef.current = volumeSeries;
});
@@ -182,27 +222,35 @@ export function PriceChart({
chart?.remove();
chartRef.current = null;
candleSeriesRef.current = null;
+ lineSeriesRef.current = null;
+ areaSeriesRef.current = null;
volumeSeriesRef.current = null;
previousCandlesRef.current = [];
};
- }, [theme, pricePrecision]);
+ }, [theme, pricePrecision, chartStyle]);
useEffect(() => {
const candleSeries = candleSeriesRef.current;
+ const lineSeries = lineSeriesRef.current;
+ const areaSeries = areaSeriesRef.current;
const volumeSeries = volumeSeriesRef.current;
const chart = chartRef.current;
- if (!candleSeries || !volumeSeries || !chart) return;
+ if ((!candleSeries && !lineSeries && !areaSeries) || !volumeSeries || !chart) return;
const colors = getChartColors();
const previous = previousCandlesRef.current;
if (isRoutineUpdate(previous, candles)) {
const candle = candles[candles.length - 1];
if (candle) {
- candleSeries.update(candleData(candle));
+ if (candleSeries) candleSeries.update(candleData(candle));
+ else if (lineSeries) lineSeries.update(lineData(candle));
+ else areaSeries?.update(lineData(candle));
volumeSeries.update(volumeData(candle, colors.volumeUp, colors.volumeDown));
}
} else {
- candleSeries.setData(candles.map(candleData));
+ if (candleSeries) candleSeries.setData(candles.map(candleData));
+ else if (lineSeries) lineSeries.setData(candles.map(lineData));
+ else areaSeries?.setData(candles.map(lineData));
volumeSeries.setData(
candles.map((candle) => volumeData(candle, colors.volumeUp, colors.volumeDown)),
);
diff --git a/frontend/src/components/market/trade-form.tsx b/frontend/src/components/market/trade-form.tsx
index 0a90a51..8c099ba 100644
--- a/frontend/src/components/market/trade-form.tsx
+++ b/frontend/src/components/market/trade-form.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { useAuth } from "@/context/AuthContext";
@@ -40,6 +40,7 @@ export function TradeForm({ symbol, loading, lastPrice, bestBid, bestAsk }: Trad
const [price, setPrice] = useState("");
const [quantity, setQuantity] = useState("");
const [percent, setPercent] = useState(0);
+ const initializedPriceSymbol = useRef(null);
const { authenticated, verified, loading: authLoading } = useAuth();
const createOrder = useCreateOrder();
const submitting = createOrder.isPending;
@@ -88,10 +89,19 @@ export function TradeForm({ symbol, loading, lastPrice, bestBid, bestAsk }: Trad
(side === "BUY" && !isPositive(effectivePrice));
useEffect(() => {
- setPrice(formatPrice(lastPrice ?? "", pricePrecision));
+ initializedPriceSymbol.current = null;
+ setPrice("");
setQuantity("");
setPercent(0);
- }, [symbol, loading]);
+ }, [symbol]);
+
+ useEffect(() => {
+ if (initializedPriceSymbol.current === symbol) return;
+ const initialPrice = editablePrice(Number(lastPrice), pricePrecision);
+ if (!initialPrice) return;
+ setPrice(initialPrice);
+ initializedPriceSymbol.current = symbol;
+ }, [lastPrice, pricePrecision, symbol]);
const setTradeSide = (nextSide: "BUY" | "SELL") => {
setSide(nextSide);
diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx
index 656b2d6..5022f94 100644
--- a/frontend/src/components/navbar.tsx
+++ b/frontend/src/components/navbar.tsx
@@ -58,7 +58,7 @@ export function Navbar() {
-
+
{NAV_LINKS.map((link) => (
;
if (!authenticated) return ;
+ if (!verified) return ;
return <>{children}>;
}
export function PublicOnly({ children }: { children: ReactNode }) {
- const { authenticated, loading } = useAuth();
+ const { authenticated, verified, loading } = useAuth();
if (loading) return ;
- if (authenticated) return ;
+ if (authenticated) return ;
return <>{children}>;
}
diff --git a/frontend/src/components/signup-form.tsx b/frontend/src/components/signup-form.tsx
index 0b39df9..0a9af21 100644
--- a/frontend/src/components/signup-form.tsx
+++ b/frontend/src/components/signup-form.tsx
@@ -33,6 +33,7 @@ export function SignupForm({ className, ...props }: React.ComponentProps<"div">)
posthog.capture("user_signed_up", { email_verified: user.emailVerified });
toast.success(message);
navigate(user.emailVerified ? "/dashboard" : "/verify-email", {
+ replace: true,
state: user.emailVerified ? undefined : { emailSent: true },
});
} catch (error) {
diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx
index bd03f21..a0a63fa 100644
--- a/frontend/src/components/ui/dropdown-menu.tsx
+++ b/frontend/src/components/ui/dropdown-menu.tsx
@@ -23,20 +23,25 @@ function DropdownMenuTrigger({
function DropdownMenuContent({
className,
sideOffset = 4,
+ portalled = true,
...props
-}: React.ComponentProps) {
- return (
-
-
-
+}: React.ComponentProps & { portalled?: boolean }) {
+ const content = (
+
+ );
+
+ return portalled ? (
+ {content}
+ ) : (
+ content
);
}
diff --git a/frontend/src/content/docs/markets.md b/frontend/src/content/docs/markets.md
index 8e7d8a1..6d3f30f 100644
--- a/frontend/src/content/docs/markets.md
+++ b/frontend/src/content/docs/markets.md
@@ -56,9 +56,9 @@ Before the first trade, price and volume values can be `null`.
## Candles
-`GET /markets/:symbol/candles?interval=15M` returns historical OHLCV candles in chronological order.
+`GET /markets/:symbol/candles?interval=15m` returns historical OHLCV candles in chronological order.
-Supported intervals: `1M`, `5M`, `15M`, `30M`, `1H`, `4H`, and `1D`. The default is `15M`.
+Supported intervals: `1m`, `5m`, `15m`, `30m`, `1H`, `4H`, and `1D`. The default is `15m`.
```bash
curl "https://api.paperdrill.dev/v1/markets/SOL_USD/candles?interval=1H"
diff --git a/frontend/src/hooks/use-candles.ts b/frontend/src/hooks/use-candles.ts
index 0a66806..20177db 100644
--- a/frontend/src/hooks/use-candles.ts
+++ b/frontend/src/hooks/use-candles.ts
@@ -3,14 +3,14 @@ import type { Candle } from "@/types";
import { api } from "@/lib/api";
import { wsManager } from "@/lib/ws";
-export const CANDLE_INTERVALS = ["1M", "5M", "15M", "30M", "1H", "4H", "1D"] as const;
+export const CANDLE_INTERVALS = ["1m", "5m", "15m", "30m", "1H", "4H", "1D"] as const;
export type CandleInterval = (typeof CANDLE_INTERVALS)[number];
export const CANDLE_INTERVAL_MS: Record = {
- "1M": 60 * 1000,
- "5M": 5 * 60 * 1000,
- "15M": 15 * 60 * 1000,
- "30M": 30 * 60 * 1000,
+ "1m": 60 * 1000,
+ "5m": 5 * 60 * 1000,
+ "15m": 15 * 60 * 1000,
+ "30m": 30 * 60 * 1000,
"1H": 60 * 60 * 1000,
"4H": 4 * 60 * 60 * 1000,
"1D": 24 * 60 * 60 * 1000,
diff --git a/frontend/src/hooks/use-trades.ts b/frontend/src/hooks/use-trades.ts
index e29a314..d4a71b7 100644
--- a/frontend/src/hooks/use-trades.ts
+++ b/frontend/src/hooks/use-trades.ts
@@ -10,6 +10,7 @@ export function useTrades(symbol: string, limit = 50) {
useEffect(() => {
let active = true;
+ setLoading(true);
setTrades([]);
setError(null);
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index b93eb5b..84a6c14 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -118,6 +118,13 @@ export const api = {
});
},
+ updateEmail(email: string) {
+ return request("/users/me/email", {
+ method: "PATCH",
+ body: JSON.stringify({ email }),
+ });
+ },
+
signout() {
return request<{ success: boolean; message: string }>("/auth/logout", {
method: "POST",
diff --git a/frontend/src/pages/trade.tsx b/frontend/src/pages/trade.tsx
index b863eb7..eaaf141 100644
--- a/frontend/src/pages/trade.tsx
+++ b/frontend/src/pages/trade.tsx
@@ -24,7 +24,6 @@ export function TradePage() {
const { orderbook, loading: orderbookLoading, bestBid, bestAsk } = useOrderbook(symbol);
const { trades, loading: tradesLoading } = useTrades(symbol);
const ticker = tickers[symbol] ?? null;
- const isDataLoading = authLoading || orderbookLoading || tradesLoading || tickerLoading;
const bookTradesTabs = (
@@ -55,7 +54,7 @@ export function TradePage() {
return (
-
+
@@ -68,12 +67,12 @@ export function TradePage() {
) : (
-
+
)}
@@ -86,18 +85,18 @@ export function TradePage() {
compact
bids={orderbook.bids}
asks={orderbook.asks}
- loading={isDataLoading}
+ loading={orderbookLoading}
symbol={symbol}
lastPrice={ticker?.lastPrice}
/>
) : (
-
+
)}
- {isDataLoading ? (
+ {tickerLoading && !ticker ? (
@@ -124,7 +123,7 @@ export function TradePage() {
-
+
diff --git a/frontend/src/pages/verify-email.tsx b/frontend/src/pages/verify-email.tsx
index 297e473..cd40876 100644
--- a/frontend/src/pages/verify-email.tsx
+++ b/frontend/src/pages/verify-email.tsx
@@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react";
import { useAnalytics } from "@/lib/analytics";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
import { useAuth } from "@/context/AuthContext";
import { api } from "@/lib/api";
import { toast } from "sonner";
@@ -13,17 +14,25 @@ type Status = "waiting" | "verifying" | "success" | "error";
export function VerifyEmailPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
- const { user, verified, loading, refreshUser } = useAuth();
+ const { user, verified, loading, refreshUser, setUser } = useAuth();
const posthog = useAnalytics();
const navigate = useNavigate();
const location = useLocation();
const emailWasJustSent = Boolean((location.state as { emailSent?: boolean } | null)?.emailSent);
const [status, setStatus] = useState
(token ? "verifying" : "waiting");
const [resending, setResending] = useState(false);
+ const [editingEmail, setEditingEmail] = useState(false);
+ const [email, setEmail] = useState("");
+ const [updatingEmail, setUpdatingEmail] = useState(false);
const [resendAt, setResendAt] = useState(() =>
emailWasJustSent ? Date.now() + 30_000 : null,
);
const [countdown, setCountdown] = useState(emailWasJustSent ? 30 : 0);
+ const verificationComplete = verified || status === "success";
+
+ useEffect(() => {
+ if (!editingEmail) setEmail(user?.email ?? "");
+ }, [editingEmail, user?.email]);
useEffect(() => {
if (!resendAt) return;
@@ -75,6 +84,9 @@ export function VerifyEmailPage() {
try {
const result = await api.resendVerificationEmail(user.email);
setResendAt(Date.now() + 30_000);
+ setCountdown(30);
+ setStatus("waiting");
+ navigate("/verify-email", { replace: true });
toast.success(result.message);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to resend verification email");
@@ -83,6 +95,33 @@ export function VerifyEmailPage() {
}
};
+ const handleEmailUpdate = async (event: React.SubmitEvent) => {
+ event.preventDefault();
+ if (!user || updatingEmail) return;
+ const nextEmail = email.trim();
+ if (nextEmail === user.email) {
+ setEditingEmail(false);
+ return;
+ }
+
+ setUpdatingEmail(true);
+ try {
+ const { message, ...updatedUser } = await api.updateEmail(nextEmail);
+ setUser(updatedUser);
+ setEditingEmail(false);
+ setResendAt(Date.now() + 30_000);
+ setCountdown(30);
+ setStatus("waiting");
+ navigate("/verify-email", { replace: true });
+ posthog.capture("verification_email_updated");
+ toast.success(message);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to update email");
+ } finally {
+ setUpdatingEmail(false);
+ }
+ };
+
if (loading || status === "verifying") {
return (
@@ -97,15 +136,17 @@ export function VerifyEmailPage() {
- {verified
+ {verificationComplete
? "Email verified"
: status === "error"
? "Verification failed"
: "Check your inbox"}
- {verified ? (
- "Your email has been successfully verified. Redirecting to the markets page..."
+ {verificationComplete ? (
+ user
+ ? "Your email has been successfully verified. Redirecting to your dashboard..."
+ : "Your email has been successfully verified. Sign in to continue."
) : user ? (
<>
To create your PaperDrill account, click the verification button in the email we
@@ -117,8 +158,49 @@ export function VerifyEmailPage() {
- {user && !verified && (
+ {user && !verificationComplete && (
<>
+ {editingEmail ? (
+
+ ) : (
+ setEditingEmail(true)}
+ >
+ Wrong email? Change it
+
+ )}
Don't see the email in your inbox or spam folder?