diff --git a/public/favicon.ico b/public/favicon.ico index d7eea1a..a35a910 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/logo.png b/public/logo.png deleted file mode 100644 index 2241778..0000000 Binary files a/public/logo.png and /dev/null differ diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..a9a98ad --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/rsbuild.config.ts b/rsbuild.config.ts index ad868c0..21346c6 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -7,71 +7,73 @@ import path from "path"; dotenv.config(); export default defineConfig({ - plugins: [pluginReact(), pluginBasicSsl()], - html: { - template: "./index.html", - title: "Champion Trader", - favicon: "public/favicon.ico", - }, - source: { - define: { - "process.env.RSBUILD_WS_URL": JSON.stringify(process.env.RSBUILD_WS_URL), - "process.env.RSBUILD_WS_PUBLIC_PATH": JSON.stringify( - process.env.RSBUILD_WS_PUBLIC_PATH - ), - "process.env.RSBUILD_WS_PROTECTED_PATH": JSON.stringify( - process.env.RSBUILD_WS_PROTECTED_PATH - ), - "process.env.RSBUILD_REST_URL": JSON.stringify( - process.env.RSBUILD_REST_URL - ), - "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), - "process.env.RSBUILD_SSE_PUBLIC_PATH": JSON.stringify( - process.env.RSBUILD_SSE_PUBLIC_PATH - ), - "process.env.RSBUILD_SSE_PROTECTED_PATH": JSON.stringify( - process.env.RSBUILD_SSE_PROTECTED_PATH - ), + plugins: [pluginReact(), pluginBasicSsl()], + html: { + template: "./index.html", + title: "Champion Trader", + favicon: "public/favicon.ico", }, - alias: { - "@": "./src", + source: { + define: { + "process.env.RSBUILD_WS_URL": JSON.stringify( + process.env.RSBUILD_WS_URL + ), + "process.env.RSBUILD_WS_PUBLIC_PATH": JSON.stringify( + process.env.RSBUILD_WS_PUBLIC_PATH + ), + "process.env.RSBUILD_WS_PROTECTED_PATH": JSON.stringify( + process.env.RSBUILD_WS_PROTECTED_PATH + ), + "process.env.RSBUILD_REST_URL": JSON.stringify( + process.env.RSBUILD_REST_URL + ), + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), + "process.env.RSBUILD_SSE_PUBLIC_PATH": JSON.stringify( + process.env.RSBUILD_SSE_PUBLIC_PATH + ), + "process.env.RSBUILD_SSE_PROTECTED_PATH": JSON.stringify( + process.env.RSBUILD_SSE_PROTECTED_PATH + ), + }, + alias: { + "@": "./src", + }, }, - }, - server: { - port: 4113, - host: "localhost", - strictPort: true, - }, - output: { - copy: [ - { - from: path.resolve( - __dirname, - "node_modules/@deriv-com/smartcharts-champion/dist" - ), - to: "js/smartcharts/", - globOptions: { - ignore: ["**/*.LICENSE.txt"] - } - }, - { - from: path.resolve( - __dirname, - "node_modules/@deriv-com/smartcharts-champion/dist/chart/assets" - ), - to: "assets", - }, - ], - filename: { - js: "[name].[hash:8].js", - css: "[name].[hash:8].css", - assets: "assets/[name].[hash:8][ext]" + server: { + port: 4113, + host: "localhost", + strictPort: true, }, - distPath: { - js: "js", - css: "css", - html: "" + output: { + copy: [ + { + from: path.resolve( + __dirname, + "node_modules/@deriv-com/smartcharts-champion/dist" + ), + to: "js/smartcharts/", + globOptions: { + ignore: ["**/*.LICENSE.txt"], + }, + }, + { + from: path.resolve( + __dirname, + "node_modules/@deriv-com/smartcharts-champion/dist/chart/assets" + ), + to: "assets", + }, + ], + filename: { + js: "[name].[hash:8].js", + css: "[name].[hash:8].css", + assets: "assets/[name].[hash:8][ext]", + }, + distPath: { + js: "js", + css: "css", + html: "", + }, + cleanDistPath: true, }, - cleanDistPath: true - }, }); diff --git a/src/adapters/__tests__/duration-config-adapter.test.ts b/src/adapters/__tests__/duration-config-adapter.test.ts index e354885..dd0f955 100644 --- a/src/adapters/__tests__/duration-config-adapter.test.ts +++ b/src/adapters/__tests__/duration-config-adapter.test.ts @@ -1,203 +1,203 @@ import { - adaptDurationRanges, - convertSecondsToDurationRanges, - getAvailableDurationTypes, + adaptDurationRanges, + convertSecondsToDurationRanges, + getAvailableDurationTypes, } from "../duration-config-adapter"; import type { ProductConfigResponse } from "@/services/api/rest/product-config/types"; describe("getAvailableDurationTypes", () => { - const mockDurationTypes = [ - { label: "Ticks", value: "ticks" }, - { label: "Seconds", value: "seconds" }, - { label: "Minutes", value: "minutes" }, - { label: "Hours", value: "hours" }, - { label: "Days", value: "days" }, - ]; - - it("returns all duration types when config is null", () => { - const result = getAvailableDurationTypes(null, mockDurationTypes); - expect(result).toEqual(mockDurationTypes); - }); - - it("filters duration types based on supported units and ranges", () => { - const mockConfig = { - data: { - defaults: { - id: "CALL", - duration: 1, - duration_units: "ticks", - allow_equals: true, - stake: 10, - }, - validations: { - durations: { - supported_units: ["ticks", "seconds"], - ticks: { min: 1, max: 10 }, - seconds: { min: 15, max: 3600 }, // 1 hour in seconds - }, - stake: { min: "0.35", max: "100000" }, - payout: { min: "0", max: "100000" }, - }, - }, - } as ProductConfigResponse; - - const result = getAvailableDurationTypes(mockConfig, mockDurationTypes); - - // Should include: - // - ticks (directly supported) - // - seconds (directly supported, 15-59s) - // - minutes (derived from seconds, 1-59m) - // - hours (derived from seconds, max 3600s = 1h) - expect(result).toHaveLength(4); // ticks, seconds, minutes, hours - const values = result.map((t) => t.value); - expect(values).toContain("ticks"); - expect(values).toContain("seconds"); - expect(values).toContain("minutes"); - expect(values).toContain("hours"); - expect(values).not.toContain("days"); - }); - - it("handles empty supported units", () => { - const mockConfig = { - data: { - defaults: { - id: "CALL", - duration: 1, - duration_units: "ticks", - allow_equals: true, - stake: 10, - }, - validations: { - durations: { - supported_units: [], - }, - stake: { min: "0.35", max: "100000" }, - payout: { min: "0", max: "100000" }, - }, - }, - } as ProductConfigResponse; - - const result = getAvailableDurationTypes(mockConfig, mockDurationTypes); - expect(result).toHaveLength(0); - }); - - it("handles time-based types when seconds are supported", () => { - const mockConfig = { - data: { - defaults: { - id: "CALL", - duration: 15, - duration_units: "seconds", - allow_equals: true, - stake: 10, - }, - validations: { - durations: { - supported_units: ["seconds"], - seconds: { min: 15, max: 3600 }, // 1 hour in seconds - }, - stake: { min: "0.35", max: "100000" }, - payout: { min: "0", max: "100000" }, - }, - }, - } as ProductConfigResponse; - - const result = getAvailableDurationTypes(mockConfig, mockDurationTypes); - - // Should include: - // - seconds (directly supported, 15-59s) - // - minutes (derived from seconds, 1-59m) - // - hours (derived from seconds, max 3600s = 1h) - expect(result).toHaveLength(3); // seconds, minutes, hours - const values = result.map((t) => t.value); - expect(values).toContain("seconds"); - expect(values).toContain("minutes"); - expect(values).toContain("hours"); - expect(values).not.toContain("ticks"); - expect(values).not.toContain("days"); - }); + const mockDurationTypes = [ + { label: "Ticks", value: "ticks" }, + { label: "Seconds", value: "seconds" }, + { label: "Minutes", value: "minutes" }, + { label: "Hours", value: "hours" }, + { label: "Days", value: "days" }, + ]; + + it("returns all duration types when config is null", () => { + const result = getAvailableDurationTypes(null, mockDurationTypes); + expect(result).toEqual(mockDurationTypes); + }); + + it("filters duration types based on supported units and ranges", () => { + const mockConfig = { + data: { + defaults: { + id: "CALL", + duration: 1, + duration_units: "ticks", + allow_equals: true, + stake: 10, + }, + validations: { + durations: { + supported_units: ["ticks", "seconds"], + ticks: { min: 1, max: 10 }, + seconds: { min: 15, max: 3600 }, // 1 hour in seconds + }, + stake: { min: "0.35", max: "100000" }, + payout: { min: "0", max: "100000" }, + }, + }, + } as ProductConfigResponse; + + const result = getAvailableDurationTypes(mockConfig, mockDurationTypes); + + // Should include: + // - ticks (directly supported) + // - seconds (directly supported, 15-59s) + // - minutes (derived from seconds, 1-59m) + // - hours (derived from seconds, max 3600s = 1h) + expect(result).toHaveLength(4); // ticks, seconds, minutes, hours + const values = result.map((t) => t.value); + expect(values).toContain("ticks"); + expect(values).toContain("seconds"); + expect(values).toContain("minutes"); + expect(values).toContain("hours"); + expect(values).not.toContain("days"); + }); + + it("handles empty supported units", () => { + const mockConfig = { + data: { + defaults: { + id: "CALL", + duration: 1, + duration_units: "ticks", + allow_equals: true, + stake: 10, + }, + validations: { + durations: { + supported_units: [], + }, + stake: { min: "0.35", max: "100000" }, + payout: { min: "0", max: "100000" }, + }, + }, + } as ProductConfigResponse; + + const result = getAvailableDurationTypes(mockConfig, mockDurationTypes); + expect(result).toHaveLength(0); + }); + + it("handles time-based types when seconds are supported", () => { + const mockConfig = { + data: { + defaults: { + id: "CALL", + duration: 15, + duration_units: "seconds", + allow_equals: true, + stake: 10, + }, + validations: { + durations: { + supported_units: ["seconds"], + seconds: { min: 15, max: 3600 }, // 1 hour in seconds + }, + stake: { min: "0.35", max: "100000" }, + payout: { min: "0", max: "100000" }, + }, + }, + } as ProductConfigResponse; + + const result = getAvailableDurationTypes(mockConfig, mockDurationTypes); + + // Should include: + // - seconds (directly supported, 15-59s) + // - minutes (derived from seconds, 1-59m) + // - hours (derived from seconds, max 3600s = 1h) + expect(result).toHaveLength(3); // seconds, minutes, hours + const values = result.map((t) => t.value); + expect(values).toContain("seconds"); + expect(values).toContain("minutes"); + expect(values).toContain("hours"); + expect(values).not.toContain("ticks"); + expect(values).not.toContain("days"); + }); }); describe("convertSecondsToDurationRanges", () => { - it("converts seconds to appropriate duration ranges", () => { - const result = convertSecondsToDurationRanges(15, 3600); + it("converts seconds to appropriate duration ranges", () => { + const result = convertSecondsToDurationRanges(15, 3600); - expect(result.seconds).toEqual({ min: 15, max: 59 }); - expect(result.minutes).toEqual({ min: 1, max: 59 }); - expect(result.hours).toEqual({ min: 1, max: 1, step: 1 }); - }); + expect(result.seconds).toEqual({ min: 15, max: 59 }); + expect(result.minutes).toEqual({ min: 1, max: 59 }); + expect(result.hours).toEqual({ min: 1, max: 1, step: 1 }); + }); - it("handles seconds only", () => { - const result = convertSecondsToDurationRanges(15, 45); + it("handles seconds only", () => { + const result = convertSecondsToDurationRanges(15, 45); - expect(result.seconds).toEqual({ min: 15, max: 45 }); - expect(result.minutes).toBeUndefined(); - expect(result.hours).toBeUndefined(); - }); + expect(result.seconds).toEqual({ min: 15, max: 45 }); + expect(result.minutes).toBeUndefined(); + expect(result.hours).toBeUndefined(); + }); - it("handles minutes without hours", () => { - const result = convertSecondsToDurationRanges(60, 1800); + it("handles minutes without hours", () => { + const result = convertSecondsToDurationRanges(60, 1800); - expect(result.seconds).toBeUndefined(); - expect(result.minutes).toEqual({ min: 1, max: 30 }); - expect(result.hours).toBeUndefined(); - }); + expect(result.seconds).toBeUndefined(); + expect(result.minutes).toEqual({ min: 1, max: 30 }); + expect(result.hours).toBeUndefined(); + }); }); describe("adaptDurationRanges", () => { - it("adapts duration ranges from API config", () => { - const mockConfig = { - data: { - defaults: { - id: "CALL", - duration: 1, - duration_units: "ticks", - allow_equals: true, - stake: 10, - }, - validations: { - durations: { - supported_units: ["ticks", "seconds"], - ticks: { min: 1, max: 10 }, - seconds: { min: 15, max: 3600 }, - }, - stake: { min: "0.35", max: "100000" }, - payout: { min: "0", max: "100000" }, - }, - }, - } as ProductConfigResponse; - - const result = adaptDurationRanges(mockConfig); - - expect(result.ticks).toEqual({ min: 1, max: 10 }); - expect(result.seconds).toEqual({ min: 15, max: 59 }); - expect(result.minutes).toEqual({ min: 1, max: 59 }); - expect(result.hours).toEqual({ min: 1, max: 1, step: 1 }); - }); - - it("uses ticks as fallback when no valid ranges", () => { - const mockConfig = { - data: { - defaults: { - id: "CALL", - duration: 1, - duration_units: "ticks", - allow_equals: true, - stake: 10, - }, - validations: { - durations: { - supported_units: [], - }, - stake: { min: "0.35", max: "100000" }, - payout: { min: "0", max: "100000" }, - }, - }, - } as ProductConfigResponse; - - const result = adaptDurationRanges(mockConfig); - - expect(result.ticks).toEqual({ min: 1, max: 10 }); - expect(Object.keys(result)).toHaveLength(1); - }); + it("adapts duration ranges from API config", () => { + const mockConfig = { + data: { + defaults: { + id: "CALL", + duration: 1, + duration_units: "ticks", + allow_equals: true, + stake: 10, + }, + validations: { + durations: { + supported_units: ["ticks", "seconds"], + ticks: { min: 1, max: 10 }, + seconds: { min: 15, max: 3600 }, + }, + stake: { min: "0.35", max: "100000" }, + payout: { min: "0", max: "100000" }, + }, + }, + } as ProductConfigResponse; + + const result = adaptDurationRanges(mockConfig); + + expect(result.ticks).toEqual({ min: 1, max: 10 }); + expect(result.seconds).toEqual({ min: 15, max: 59 }); + expect(result.minutes).toEqual({ min: 1, max: 59 }); + expect(result.hours).toEqual({ min: 1, max: 1, step: 1 }); + }); + + it("uses ticks as fallback when no valid ranges", () => { + const mockConfig = { + data: { + defaults: { + id: "CALL", + duration: 1, + duration_units: "ticks", + allow_equals: true, + stake: 10, + }, + validations: { + durations: { + supported_units: [], + }, + stake: { min: "0.35", max: "100000" }, + payout: { min: "0", max: "100000" }, + }, + }, + } as ProductConfigResponse; + + const result = adaptDurationRanges(mockConfig); + + expect(result.ticks).toEqual({ min: 1, max: 10 }); + expect(Object.keys(result)).toHaveLength(1); + }); }); diff --git a/src/adapters/__tests__/stake-config-adapter.test.ts b/src/adapters/__tests__/stake-config-adapter.test.ts index 9a0b6f7..a4e6103 100644 --- a/src/adapters/__tests__/stake-config-adapter.test.ts +++ b/src/adapters/__tests__/stake-config-adapter.test.ts @@ -1,109 +1,109 @@ import { - adaptStakeConfig, - updateStakeConfig, - getStakeConfig, - adaptDefaultStake, + adaptStakeConfig, + updateStakeConfig, + getStakeConfig, + adaptDefaultStake, } from "../stake-config-adapter"; import type { ProductConfigResponse } from "@/services/api/rest/product-config/types"; describe("adaptStakeConfig", () => { - it("adapts stake configuration from API response", () => { - const mockConfig = { - data: { - defaults: { - id: "CALL", - duration: 1, - duration_units: "ticks", - allow_equals: true, - stake: 10, - }, - validations: { - durations: { - supported_units: ["ticks", "seconds"], - ticks: { min: 1, max: 10 }, - seconds: { min: 15, max: 3600 }, - }, - stake: { min: "5.00", max: "2500.00" }, - payout: { min: "0", max: "100000" }, - }, - }, - } as ProductConfigResponse; + it("adapts stake configuration from API response", () => { + const mockConfig = { + data: { + defaults: { + id: "CALL", + duration: 1, + duration_units: "ticks", + allow_equals: true, + stake: 10, + }, + validations: { + durations: { + supported_units: ["ticks", "seconds"], + ticks: { min: 1, max: 10 }, + seconds: { min: 15, max: 3600 }, + }, + stake: { min: "5.00", max: "2500.00" }, + payout: { min: "0", max: "100000" }, + }, + }, + } as ProductConfigResponse; - const result = adaptStakeConfig(mockConfig); + const result = adaptStakeConfig(mockConfig); - expect(result.min).toEqual(5); - expect(result.max).toEqual(2500); - expect(result.step).toEqual(1); // Preserved from default - }); + expect(result.min).toEqual(5); + expect(result.max).toEqual(2500); + expect(result.step).toEqual(1); // Preserved from default + }); }); describe("updateStakeConfig and getStakeConfig", () => { - it("updates and retrieves stake configuration", () => { - const newConfig = { - min: 10, - max: 1000, - step: 5, - }; + it("updates and retrieves stake configuration", () => { + const newConfig = { + min: 10, + max: 1000, + step: 5, + }; - updateStakeConfig(newConfig); - const result = getStakeConfig(); + updateStakeConfig(newConfig); + const result = getStakeConfig(); - expect(result).toEqual(newConfig); - expect(result).not.toBe(newConfig); // Should be a copy, not the same reference - }); + expect(result).toEqual(newConfig); + expect(result).not.toBe(newConfig); // Should be a copy, not the same reference + }); - it("preserves step value when updating", () => { - const initialConfig = { - min: 1, - max: 100, - step: 2, - }; + it("preserves step value when updating", () => { + const initialConfig = { + min: 1, + max: 100, + step: 2, + }; - updateStakeConfig(initialConfig); + updateStakeConfig(initialConfig); - const mockConfig = { - data: { - defaults: { - stake: 10, - }, - validations: { - stake: { min: "5.00", max: "50.00" }, - }, - }, - } as ProductConfigResponse; + const mockConfig = { + data: { + defaults: { + stake: 10, + }, + validations: { + stake: { min: "5.00", max: "50.00" }, + }, + }, + } as ProductConfigResponse; - const result = adaptStakeConfig(mockConfig); + const result = adaptStakeConfig(mockConfig); - expect(result.min).toEqual(5); - expect(result.max).toEqual(50); - expect(result.step).toEqual(2); // Should preserve step from previous config - }); + expect(result.min).toEqual(5); + expect(result.max).toEqual(50); + expect(result.step).toEqual(2); // Should preserve step from previous config + }); }); describe("adaptDefaultStake", () => { - it("gets default stake value from product config", () => { - const mockConfig = { - data: { - defaults: { - stake: 25, - }, - }, - } as ProductConfigResponse; + it("gets default stake value from product config", () => { + const mockConfig = { + data: { + defaults: { + stake: 25, + }, + }, + } as ProductConfigResponse; - const result = adaptDefaultStake(mockConfig); - expect(result).toEqual("25"); - }); + const result = adaptDefaultStake(mockConfig); + expect(result).toEqual("25"); + }); - it("handles decimal stake values", () => { - const mockConfig = { - data: { - defaults: { - stake: 10.5, - }, - }, - } as ProductConfigResponse; + it("handles decimal stake values", () => { + const mockConfig = { + data: { + defaults: { + stake: 10.5, + }, + }, + } as ProductConfigResponse; - const result = adaptDefaultStake(mockConfig); - expect(result).toEqual("10.5"); - }); + const result = adaptDefaultStake(mockConfig); + expect(result).toEqual("10.5"); + }); }); diff --git a/src/adapters/duration-config-adapter.ts b/src/adapters/duration-config-adapter.ts index 705a7c9..2fd520e 100644 --- a/src/adapters/duration-config-adapter.ts +++ b/src/adapters/duration-config-adapter.ts @@ -8,151 +8,150 @@ import { ProductConfigResponse } from "@/services/api/rest/product-config/types" * @param allDurationTypes - All available duration types * @returns Filtered duration types based on API support and valid ranges */ -export function getAvailableDurationTypes< - T extends { value: string; label: string } ->(config: ProductConfigResponse | null, allDurationTypes: T[]): T[] { - if (!config?.data?.validations?.durations) { - return allDurationTypes; - } - - const { durations } = config.data.validations; - const ranges = adaptDurationRanges(config); - - // Only show duration types that have valid ranges - return allDurationTypes.filter((type) => { - const hasRange = ranges[type.value as keyof DurationRangesResponse]; - const isTimeBasedType = type.value === "minutes" || type.value === "hours"; - const isSupported = isTimeBasedType - ? durations.supported_units.includes("seconds") // minutes and hours are derived from seconds - : durations.supported_units.includes(type.value); - - return isSupported && hasRange; - }); +export function getAvailableDurationTypes( + config: ProductConfigResponse | null, + allDurationTypes: T[] +): T[] { + if (!config?.data?.validations?.durations) { + return allDurationTypes; + } + + const { durations } = config.data.validations; + const ranges = adaptDurationRanges(config); + + // Only show duration types that have valid ranges + return allDurationTypes.filter((type) => { + const hasRange = ranges[type.value as keyof DurationRangesResponse]; + const isTimeBasedType = type.value === "minutes" || type.value === "hours"; + const isSupported = isTimeBasedType + ? durations.supported_units.includes("seconds") // minutes and hours are derived from seconds + : durations.supported_units.includes(type.value); + + return isSupported && hasRange; + }); } /** * Converts seconds to appropriate duration ranges for each type */ export const convertSecondsToDurationRanges = ( - minSeconds: number, - maxSeconds: number + minSeconds: number, + maxSeconds: number ): Partial => { - const result: Partial = {}; - - // Handle seconds (1-59) - if (minSeconds < 60) { - result.seconds = { - min: minSeconds, - max: Math.min(59, maxSeconds), - }; - } - - // Handle minutes (1-59) - if (maxSeconds >= 60) { - result.minutes = { - min: 1, - max: Math.min(59, Math.floor(maxSeconds / 60)), - }; - } - - // Handle hours (1-24) - if (maxSeconds >= 3600) { - result.hours = { - min: 1, - max: Math.min(24, Math.floor(maxSeconds / 3600)), - step: 1, - }; - } - - return result; + const result: Partial = {}; + + // Handle seconds (1-59) + if (minSeconds < 60) { + result.seconds = { + min: minSeconds, + max: Math.min(59, maxSeconds), + }; + } + + // Handle minutes (1-59) + if (maxSeconds >= 60) { + result.minutes = { + min: 1, + max: Math.min(59, Math.floor(maxSeconds / 60)), + }; + } + + // Handle hours (1-24) + if (maxSeconds >= 3600) { + result.hours = { + min: 1, + max: Math.min(24, Math.floor(maxSeconds / 3600)), + step: 1, + }; + } + + return result; }; /** * Converts product config response to internal duration ranges format */ -export const adaptDurationRanges = ( - config: ProductConfigResponse -): DurationRangesResponse => { - const { durations } = config.data.validations; - const result: Partial = {}; - - // Process each supported unit - durations.supported_units.forEach((unit) => { - // Handle ticks separately as they're not time-based - if (unit === "ticks" && durations.ticks) { - result.ticks = { - min: durations.ticks.min, - max: durations.ticks.max, - }; - } - - // Handle seconds-based units (seconds, minutes, hours) - if (unit === "seconds" && durations.seconds) { - // Convert seconds range to appropriate ranges for each duration type - const secondsRanges = convertSecondsToDurationRanges( - durations.seconds.min, - durations.seconds.max - ); - - // Merge the converted ranges - Object.assign(result, secondsRanges); +export const adaptDurationRanges = (config: ProductConfigResponse): DurationRangesResponse => { + const { durations } = config.data.validations; + const result: Partial = {}; + + // Process each supported unit + durations.supported_units.forEach((unit) => { + // Handle ticks separately as they're not time-based + if (unit === "ticks" && durations.ticks) { + result.ticks = { + min: durations.ticks.min, + max: durations.ticks.max, + }; + } + + // Handle seconds-based units (seconds, minutes, hours) + if (unit === "seconds" && durations.seconds) { + // Convert seconds range to appropriate ranges for each duration type + const secondsRanges = convertSecondsToDurationRanges( + durations.seconds.min, + durations.seconds.max + ); + + // Merge the converted ranges + Object.assign(result, secondsRanges); + } + + // Handle days directly from API + if (unit === "days" && durations.days) { + result.days = { + min: durations.days.min, + max: durations.days.max, + }; + } + }); + + // Only include duration types that are supported by the API + // and have valid ranges from the conversion + const finalResult: DurationRangesResponse = {} as DurationRangesResponse; + + // Add each duration type only if it has a valid range + if (result.ticks) finalResult.ticks = result.ticks; + if (result.seconds) finalResult.seconds = result.seconds; + if (result.minutes) finalResult.minutes = result.minutes; + if (result.hours) finalResult.hours = result.hours; + if (result.days) finalResult.days = result.days; + + // Ensure at least one duration type is available + if (Object.keys(finalResult).length === 0) { + // Use ticks as fallback if no valid ranges + finalResult.ticks = { min: 1, max: 10 }; } - // Handle days directly from API - if (unit === "days" && durations.days) { - result.days = { - min: durations.days.min, - max: durations.days.max, - }; - } - }); - - // Only include duration types that are supported by the API - // and have valid ranges from the conversion - const finalResult: DurationRangesResponse = {} as DurationRangesResponse; - - // Add each duration type only if it has a valid range - if (result.ticks) finalResult.ticks = result.ticks; - if (result.seconds) finalResult.seconds = result.seconds; - if (result.minutes) finalResult.minutes = result.minutes; - if (result.hours) finalResult.hours = result.hours; - if (result.days) finalResult.days = result.days; - - // Ensure at least one duration type is available - if (Object.keys(finalResult).length === 0) { - // Use ticks as fallback if no valid ranges - finalResult.ticks = { min: 1, max: 10 }; - } - - return finalResult; + return finalResult; }; /** * Converts API default duration to internal format */ export const adaptDefaultDuration = (config: ProductConfigResponse): string => { - const { duration, duration_units } = config.data.defaults; - - // If the default duration is in seconds, convert to the most appropriate unit - if (duration_units === "seconds") { - if (duration >= 3600) { - // 1 hour in seconds - const hours = Math.floor(duration / 3600); - const minutes = Math.floor((duration % 3600) / 60); - return `${hours}:${minutes.toString().padStart(2, "0")} hours`; + const { duration, duration_units } = config.data.defaults; + + // If the default duration is in seconds, convert to the most appropriate unit + if (duration_units === "seconds") { + if (duration >= 3600) { + // 1 hour in seconds + const hours = Math.floor(duration / 3600); + const minutes = Math.floor((duration % 3600) / 60); + return `${hours}:${minutes.toString().padStart(2, "0")} hours`; + } + if (duration >= 60) { + // 1 minute in seconds + return `${Math.floor(duration / 60)} minutes`; + } + return `${duration} seconds`; } - if (duration >= 60) { - // 1 minute in seconds - return `${Math.floor(duration / 60)} minutes`; - } - return `${duration} seconds`; - } - // For other units, use as is - if (duration_units === "hours") { - // For hours, use the HH:MM format - return `${duration}:00 hours`; - } + // For other units, use as is + if (duration_units === "hours") { + // For hours, use the HH:MM format + return `${duration}:00 hours`; + } - return `${duration} ${duration_units}`; + return `${duration} ${duration_units}`; }; diff --git a/src/components/AccountSwitcher/AccountSwitcher.tsx b/src/components/AccountSwitcher/AccountSwitcher.tsx index 97f9030..d83c811 100644 --- a/src/components/AccountSwitcher/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher/AccountSwitcher.tsx @@ -21,9 +21,7 @@ export const AccountSwitcher: React.FC = () => { - ))} - - ), - Tab: () => null, + TabList: ({ tabs = [], selectedValue, onSelect }: any) => ( +
+ {tabs?.map((tab: any) => ( + + ))} +
+ ), + Tab: () => null, })); jest.mock("../components/DurationValueList", () => ({ - DurationValueList: ({ selectedValue, onValueSelect, onValueClick }: any) => ( -
- -
- ), + DurationValueList: ({ selectedValue, onValueSelect, onValueClick }: any) => ( +
+ +
+ ), })); jest.mock("../components/HoursDurationValue", () => ({ - HoursDurationValue: ({ onValueSelect, onValueClick }: any) => ( -
- -
- ), + HoursDurationValue: ({ onValueSelect, onValueClick }: any) => ( +
+ +
+ ), })); describe("DurationController", () => { - const mockSetDuration = jest.fn(); - const mockSetBottomSheet = jest.fn(); - const mockOnClose = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock store values - ( - useTradeStore as jest.MockedFunction - ).mockReturnValue({ - duration: "1 minutes", - setDuration: mockSetDuration, - productConfig: { - data: { - validations: { - durations: { - supported_units: ["ticks", "seconds", "minutes", "hours", "days"], + const mockSetDuration = jest.fn(); + const mockSetBottomSheet = jest.fn(); + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock store values + (useTradeStore as jest.MockedFunction).mockReturnValue({ + duration: "1 minutes", + setDuration: mockSetDuration, + productConfig: { + data: { + validations: { + durations: { + supported_units: ["ticks", "seconds", "minutes", "hours", "days"], + }, + }, + }, }, - }, - }, - }, - }); + }); - ( - useOrientationStore as jest.MockedFunction - ).mockReturnValue({ - isLandscape: false, - }); + (useOrientationStore as jest.MockedFunction).mockReturnValue({ + isLandscape: false, + }); - ( - useBottomSheetStore as jest.MockedFunction - ).mockReturnValue({ - setBottomSheet: mockSetBottomSheet, + (useBottomSheetStore as jest.MockedFunction).mockReturnValue({ + setBottomSheet: mockSetBottomSheet, + }); }); - }); - - it("handles duration value selection", () => { - render(); - - // Select value in minutes tab - fireEvent.click(screen.getByTestId("value-select")); - - // Should update duration in store - expect(mockSetDuration).toHaveBeenCalledWith("2 minutes"); - }); - - it("uses default duration value when switching to new tab", () => { - ( - useTradeStore as jest.MockedFunction - ).mockReturnValue({ - duration: "1 minutes", - setDuration: mockSetDuration, - productConfig: { - data: { - validations: { - durations: { - supported_units: ["ticks", "seconds", "minutes", "hours", "days"], - ticks: { min: 1, max: 10 }, - }, - }, - }, - }, + + it("handles duration value selection", () => { + render(); + + // Select value in minutes tab + fireEvent.click(screen.getByTestId("value-select")); + + // Should update duration in store + expect(mockSetDuration).toHaveBeenCalledWith("2 minutes"); }); - render(); + it("uses default duration value when switching to new tab", () => { + (useTradeStore as jest.MockedFunction).mockReturnValue({ + duration: "1 minutes", + setDuration: mockSetDuration, + productConfig: { + data: { + validations: { + durations: { + supported_units: ["ticks", "seconds", "minutes", "hours", "days"], + ticks: { min: 1, max: 10 }, + }, + }, + }, + }, + }); - // Switch to ticks tab - fireEvent.click(screen.getByTestId("tab-ticks")); + render(); - // Should use min value (1) from duration range as default - const valueList = screen.getByTestId("mock-duration-value-list"); - expect(valueList).toHaveAttribute("data-selected-value", "1"); - }); + // Switch to ticks tab + fireEvent.click(screen.getByTestId("tab-ticks")); - it("handles hours duration selection", () => { - render(); + // Should use min value (1) from duration range as default + const valueList = screen.getByTestId("mock-duration-value-list"); + expect(valueList).toHaveAttribute("data-selected-value", "1"); + }); - // Switch to hours tab - fireEvent.click(screen.getByTestId("tab-hours")); + it("handles hours duration selection", () => { + render(); - // Select hours value - fireEvent.click(screen.getByTestId("hours-select")); + // Switch to hours tab + fireEvent.click(screen.getByTestId("tab-hours")); - // Should update duration in store - expect(mockSetDuration).toHaveBeenCalledWith("3:00 hours"); - }); + // Select hours value + fireEvent.click(screen.getByTestId("hours-select")); - it("handles save in portrait mode", () => { - render(); + // Should update duration in store + expect(mockSetDuration).toHaveBeenCalledWith("3:00 hours"); + }); - // Click save button - fireEvent.click(screen.getByText("Save")); + it("handles save in portrait mode", () => { + render(); - // Should close bottom sheet - expect(mockSetBottomSheet).toHaveBeenCalledWith(false); - expect(mockOnClose).not.toHaveBeenCalled(); - }); + // Click save button + fireEvent.click(screen.getByText("Save")); - it("handles save in landscape mode", () => { - ( - useOrientationStore as jest.MockedFunction - ).mockReturnValue({ - isLandscape: true, + // Should close bottom sheet + expect(mockSetBottomSheet).toHaveBeenCalledWith(false); + expect(mockOnClose).not.toHaveBeenCalled(); }); - render(); + it("handles save in landscape mode", () => { + (useOrientationStore as jest.MockedFunction).mockReturnValue({ + isLandscape: true, + }); - // Select a value (auto-saves in landscape) - fireEvent.click(screen.getByTestId("value-select")); + render(); - // Should call onClose - expect(mockOnClose).toHaveBeenCalled(); - expect(mockSetBottomSheet).not.toHaveBeenCalled(); - }); + // Select a value (auto-saves in landscape) + fireEvent.click(screen.getByTestId("value-select")); + + // Should call onClose + expect(mockOnClose).toHaveBeenCalled(); + expect(mockSetBottomSheet).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/Duration/__tests__/DurationField.test.tsx b/src/components/Duration/__tests__/DurationField.test.tsx index 61bc04b..63296d3 100644 --- a/src/components/Duration/__tests__/DurationField.test.tsx +++ b/src/components/Duration/__tests__/DurationField.test.tsx @@ -6,226 +6,205 @@ import { useBottomSheetStore } from "@/stores/bottomSheetStore"; // Mock components jest.mock("@/components/TradeFields/TradeParam", () => ({ - __esModule: true, - default: ({ label, value, onClick }: any) => ( - - ), + __esModule: true, + default: ({ label, value, onClick }: any) => ( + + ), })); jest.mock("@/components/ui/desktop-trade-field-card", () => ({ - DesktopTradeFieldCard: ({ children, isSelected }: any) => ( -
- {children} -
- ), + DesktopTradeFieldCard: ({ children, isSelected }: any) => ( +
+ {children} +
+ ), })); jest.mock("@/components/ui/popover", () => ({ - Popover: ({ children }: any) =>
{children}
, + Popover: ({ children }: any) =>
{children}
, })); jest.mock("../DurationController", () => ({ - DurationController: ({ onClose }: { onClose: () => void }) => ( -
- -
- ), + DurationController: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), })); // Mock stores jest.mock("@/stores/tradeStore", () => ({ - useTradeStore: jest.fn(), + useTradeStore: jest.fn(), })); jest.mock("@/stores/orientationStore", () => ({ - useOrientationStore: jest.fn(), + useOrientationStore: jest.fn(), })); jest.mock("@/stores/bottomSheetStore", () => ({ - useBottomSheetStore: jest.fn(), + useBottomSheetStore: jest.fn(), })); describe("DurationField", () => { - const mockSetBottomSheet = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - - // Default store mocks - ( - useTradeStore as jest.MockedFunction - ).mockReturnValue({ - duration: "1 minutes", - isConfigLoading: false, - }); - - ( - useOrientationStore as jest.MockedFunction - ).mockReturnValue({ - isLandscape: false, - }); - - ( - useBottomSheetStore as jest.MockedFunction - ).mockReturnValue({ - setBottomSheet: mockSetBottomSheet, - }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - describe("Loading State", () => { - it("should show skeleton loader when config is loading", () => { - ( - useTradeStore as jest.MockedFunction - ).mockReturnValue({ - duration: "1 minutes", - isConfigLoading: true, - }); - - render(); - - const skeleton = screen.getByTestId("duration-field-skeleton"); - expect(skeleton).toBeInTheDocument(); - expect(screen.queryByTestId("trade-param")).not.toBeInTheDocument(); - }); + const mockSetBottomSheet = jest.fn(); - it("should show duration value when not loading", () => { - render(); - - const param = screen.getByTestId("trade-param"); - expect(param).toBeInTheDocument(); - expect(param).toHaveAttribute("data-label", "Duration"); - expect(param).toHaveAttribute("data-value", "1 minutes"); - expect( - screen.queryByTestId("duration-field-skeleton") - ).not.toBeInTheDocument(); - }); - }); - - describe("Portrait Mode", () => { beforeEach(() => { - ( - useOrientationStore as jest.MockedFunction - ).mockReturnValue({ - isLandscape: false, - }); + jest.clearAllMocks(); + jest.useFakeTimers(); + + // Default store mocks + (useTradeStore as jest.MockedFunction).mockReturnValue({ + duration: "1 minutes", + isConfigLoading: false, + }); + + (useOrientationStore as jest.MockedFunction).mockReturnValue({ + isLandscape: false, + }); + + (useBottomSheetStore as jest.MockedFunction).mockReturnValue({ + setBottomSheet: mockSetBottomSheet, + }); }); - it("should open bottom sheet when clicked", () => { - render(); - - fireEvent.click(screen.getByTestId("trade-param")); - - expect(mockSetBottomSheet).toHaveBeenCalledWith( - true, - "duration", - "470px" - ); + afterEach(() => { + jest.useRealTimers(); }); - it("should not render popover", () => { - render(); - - fireEvent.click(screen.getByTestId("trade-param")); - - expect(screen.queryByTestId("popover")).not.toBeInTheDocument(); + describe("Loading State", () => { + it("should show skeleton loader when config is loading", () => { + (useTradeStore as jest.MockedFunction).mockReturnValue({ + duration: "1 minutes", + isConfigLoading: true, + }); + + render(); + + const skeleton = screen.getByTestId("duration-field-skeleton"); + expect(skeleton).toBeInTheDocument(); + expect(screen.queryByTestId("trade-param")).not.toBeInTheDocument(); + }); + + it("should show duration value when not loading", () => { + render(); + + const param = screen.getByTestId("trade-param"); + expect(param).toBeInTheDocument(); + expect(param).toHaveAttribute("data-label", "Duration"); + expect(param).toHaveAttribute("data-value", "1 minutes"); + expect(screen.queryByTestId("duration-field-skeleton")).not.toBeInTheDocument(); + }); }); - it("should not wrap in desktop trade field card", () => { - render(); + describe("Portrait Mode", () => { + beforeEach(() => { + ( + useOrientationStore as jest.MockedFunction + ).mockReturnValue({ + isLandscape: false, + }); + }); - expect( - screen.queryByTestId("desktop-trade-field-card") - ).not.toBeInTheDocument(); - }); - }); + it("should open bottom sheet when clicked", () => { + render(); - describe("Landscape Mode", () => { - beforeEach(() => { - ( - useOrientationStore as jest.MockedFunction - ).mockReturnValue({ - isLandscape: true, - }); - }); + fireEvent.click(screen.getByTestId("trade-param")); - it("should wrap in desktop trade field card", () => { - render(); + expect(mockSetBottomSheet).toHaveBeenCalledWith(true, "duration", "470px"); + }); - const card = screen.getByTestId("desktop-trade-field-card"); - expect(card).toBeInTheDocument(); - expect(card).toHaveAttribute("data-selected", "false"); - }); + it("should not render popover", () => { + render(); - it("should show duration controller in popover when clicked", () => { - render(); + fireEvent.click(screen.getByTestId("trade-param")); - fireEvent.click(screen.getByTestId("trade-param")); + expect(screen.queryByTestId("popover")).not.toBeInTheDocument(); + }); - expect(screen.getByTestId("popover")).toBeInTheDocument(); - expect(screen.getByTestId("duration-controller")).toBeInTheDocument(); - expect(screen.getByTestId("desktop-trade-field-card")).toHaveAttribute( - "data-selected", - "true" - ); - }); + it("should not wrap in desktop trade field card", () => { + render(); - it("should close popover when close button clicked", () => { - render(); - - // Open popover - fireEvent.click(screen.getByTestId("trade-param")); - expect(screen.getByTestId("duration-controller")).toBeInTheDocument(); - expect(screen.getByTestId("desktop-trade-field-card")).toHaveAttribute( - "data-selected", - "true" - ); - - // Close popover - fireEvent.click(screen.getByTestId("duration-controller-close")); - expect(screen.queryByTestId("popover")).not.toBeInTheDocument(); - expect(screen.getByTestId("desktop-trade-field-card")).toHaveAttribute( - "data-selected", - "false" - ); + expect(screen.queryByTestId("desktop-trade-field-card")).not.toBeInTheDocument(); + }); }); - it("should prevent reopening during closing animation", () => { - render(); - - // Open popover - fireEvent.click(screen.getByTestId("trade-param")); - expect(screen.getByTestId("duration-controller")).toBeInTheDocument(); - - // Close popover - fireEvent.click(screen.getByTestId("duration-controller-close")); - - // Try to reopen immediately - fireEvent.click(screen.getByTestId("trade-param")); - expect(screen.queryByTestId("popover")).not.toBeInTheDocument(); - - // After animation delay - act(() => { - jest.advanceTimersByTime(300); - }); - - // Should be able to open again - fireEvent.click(screen.getByTestId("trade-param")); - expect(screen.getByTestId("popover")).toBeInTheDocument(); - expect(screen.getByTestId("duration-controller")).toBeInTheDocument(); + describe("Landscape Mode", () => { + beforeEach(() => { + ( + useOrientationStore as jest.MockedFunction + ).mockReturnValue({ + isLandscape: true, + }); + }); + + it("should wrap in desktop trade field card", () => { + render(); + + const card = screen.getByTestId("desktop-trade-field-card"); + expect(card).toBeInTheDocument(); + expect(card).toHaveAttribute("data-selected", "false"); + }); + + it("should show duration controller in popover when clicked", () => { + render(); + + fireEvent.click(screen.getByTestId("trade-param")); + + expect(screen.getByTestId("popover")).toBeInTheDocument(); + expect(screen.getByTestId("duration-controller")).toBeInTheDocument(); + expect(screen.getByTestId("desktop-trade-field-card")).toHaveAttribute( + "data-selected", + "true" + ); + }); + + it("should close popover when close button clicked", () => { + render(); + + // Open popover + fireEvent.click(screen.getByTestId("trade-param")); + expect(screen.getByTestId("duration-controller")).toBeInTheDocument(); + expect(screen.getByTestId("desktop-trade-field-card")).toHaveAttribute( + "data-selected", + "true" + ); + + // Close popover + fireEvent.click(screen.getByTestId("duration-controller-close")); + expect(screen.queryByTestId("popover")).not.toBeInTheDocument(); + expect(screen.getByTestId("desktop-trade-field-card")).toHaveAttribute( + "data-selected", + "false" + ); + }); + + it("should prevent reopening during closing animation", () => { + render(); + + // Open popover + fireEvent.click(screen.getByTestId("trade-param")); + expect(screen.getByTestId("duration-controller")).toBeInTheDocument(); + + // Close popover + fireEvent.click(screen.getByTestId("duration-controller-close")); + + // Try to reopen immediately + fireEvent.click(screen.getByTestId("trade-param")); + expect(screen.queryByTestId("popover")).not.toBeInTheDocument(); + + // After animation delay + act(() => { + jest.advanceTimersByTime(300); + }); + + // Should be able to open again + fireEvent.click(screen.getByTestId("trade-param")); + expect(screen.getByTestId("popover")).toBeInTheDocument(); + expect(screen.getByTestId("duration-controller")).toBeInTheDocument(); + }); }); - }); }); diff --git a/src/components/Duration/components/DurationValueList.tsx b/src/components/Duration/components/DurationValueList.tsx index cdb99ac..4095117 100644 --- a/src/components/Duration/components/DurationValueList.tsx +++ b/src/components/Duration/components/DurationValueList.tsx @@ -3,52 +3,49 @@ import { ScrollSelect } from "@/components/ui/scroll-select"; import type { DurationRangesResponse } from "@/services/api/rest/duration/types"; interface DurationValueListProps { - selectedValue: number; - durationType: keyof DurationRangesResponse; - onValueSelect: (value: number) => void; - onValueClick?: (value: number) => void; - getDurationValues: (type: keyof DurationRangesResponse) => number[]; + selectedValue: number; + durationType: keyof DurationRangesResponse; + onValueSelect: (value: number) => void; + onValueClick?: (value: number) => void; + getDurationValues: (type: keyof DurationRangesResponse) => number[]; } -const getUnitLabel = ( - type: keyof DurationRangesResponse, - value: number -): string => { - switch (type) { - case "ticks": - return value === 1 ? "tick" : "ticks"; - case "seconds": - return value === 1 ? "second" : "seconds"; - case "minutes": - return value === 1 ? "minute" : "minutes"; - case "hours": - return value === 1 ? "hour" : "hours"; - case "days": - return value === 1 ? "day" : "days"; - default: - return ""; - } +const getUnitLabel = (type: keyof DurationRangesResponse, value: number): string => { + switch (type) { + case "ticks": + return value === 1 ? "tick" : "ticks"; + case "seconds": + return value === 1 ? "second" : "seconds"; + case "minutes": + return value === 1 ? "minute" : "minutes"; + case "hours": + return value === 1 ? "hour" : "hours"; + case "days": + return value === 1 ? "day" : "days"; + default: + return ""; + } }; export const DurationValueList: React.FC = ({ - selectedValue, - durationType, - onValueSelect, - onValueClick, - getDurationValues, + selectedValue, + durationType, + onValueSelect, + onValueClick, + getDurationValues, }) => { - const values = getDurationValues(durationType); - const options = values.map((value) => ({ - value, - label: `${value} ${getUnitLabel(durationType, value)}`, - })); + const values = getDurationValues(durationType); + const options = values.map((value) => ({ + value, + label: `${value} ${getUnitLabel(durationType, value)}`, + })); - return ( - - ); + return ( + + ); }; diff --git a/src/components/Duration/components/HoursDurationValue.tsx b/src/components/Duration/components/HoursDurationValue.tsx index 2f5b148..2757a89 100644 --- a/src/components/Duration/components/HoursDurationValue.tsx +++ b/src/components/Duration/components/HoursDurationValue.tsx @@ -3,108 +3,102 @@ import { DurationValueList } from "./DurationValueList"; import { generateDurationValues, getSpecialCaseKey } from "@/utils/duration"; interface HoursDurationValueProps { - selectedValue: string; // "2:12" format - onValueSelect: (value: string) => void; - onValueClick?: (value: string) => void; - isInitialRender: MutableRefObject; + selectedValue: string; // "2:12" format + onValueSelect: (value: string) => void; + onValueClick?: (value: string) => void; + isInitialRender: MutableRefObject; } const getHoursValues = (): number[] => generateDurationValues("hours"); export const HoursDurationValue: React.FC = ({ - selectedValue, - onValueSelect, - onValueClick, - isInitialRender, + selectedValue, + onValueSelect, + onValueClick, + isInitialRender, }) => { - // Use refs to store last valid values - const lastValidHours = useRef(); - const lastValidMinutes = useRef(); - const minutesRef = useRef(null); + // Use refs to store last valid values + const lastValidHours = useRef(); + const lastValidMinutes = useRef(); + const minutesRef = useRef(null); - const scrollToMinutes = (value: number) => { - const minutesContainer = minutesRef.current?.querySelector( - `[data-value="${value}"]` - ); - if (minutesContainer) { - minutesContainer.scrollIntoView({ block: "center", behavior: "instant" }); - } - }; - - const scrollToZeroMinutes = () => { - if (isInitialRender.current) { - isInitialRender.current = false; - return; - } - scrollToMinutes(0); - }; + const scrollToMinutes = (value: number) => { + const minutesContainer = minutesRef.current?.querySelector(`[data-value="${value}"]`); + if (minutesContainer) { + minutesContainer.scrollIntoView({ block: "center", behavior: "instant" }); + } + }; - // Initialize refs if they're undefined - if (!lastValidHours.current || !lastValidMinutes.current) { - const [h, m] = selectedValue.split(":").map(Number); - lastValidHours.current = h; - lastValidMinutes.current = m; - } + const scrollToZeroMinutes = () => { + if (isInitialRender.current) { + isInitialRender.current = false; + return; + } + scrollToMinutes(0); + }; - const handleHoursSelect = (newHours: number) => { - lastValidHours.current = newHours; - if (isInitialRender.current) { - onValueSelect(`${newHours}:${lastValidMinutes.current}`); - } else { - lastValidMinutes.current = 0; - onValueSelect(`${newHours}:00`); + // Initialize refs if they're undefined + if (!lastValidHours.current || !lastValidMinutes.current) { + const [h, m] = selectedValue.split(":").map(Number); + lastValidHours.current = h; + lastValidMinutes.current = m; } - scrollToZeroMinutes(); - }; - const handleMinutesSelect = (newMinutes: number) => { - lastValidMinutes.current = newMinutes; - onValueSelect(`${lastValidHours.current}:${newMinutes}`); - }; + const handleHoursSelect = (newHours: number) => { + lastValidHours.current = newHours; + if (isInitialRender.current) { + onValueSelect(`${newHours}:${lastValidMinutes.current}`); + } else { + lastValidMinutes.current = 0; + onValueSelect(`${newHours}:00`); + } + scrollToZeroMinutes(); + }; - const handleHoursClick = (newHours: number) => { - lastValidHours.current = newHours; - if (isInitialRender.current) { - onValueClick?.(`${newHours}:${lastValidMinutes.current}`); - } else { - lastValidMinutes.current = 0; - onValueClick?.(`${newHours}:00`); - } - scrollToZeroMinutes(); - }; + const handleMinutesSelect = (newMinutes: number) => { + lastValidMinutes.current = newMinutes; + onValueSelect(`${lastValidHours.current}:${newMinutes}`); + }; + + const handleHoursClick = (newHours: number) => { + lastValidHours.current = newHours; + if (isInitialRender.current) { + onValueClick?.(`${newHours}:${lastValidMinutes.current}`); + } else { + lastValidMinutes.current = 0; + onValueClick?.(`${newHours}:00`); + } + scrollToZeroMinutes(); + }; - const handleMinutesClick = (newMinutes: number) => { - lastValidMinutes.current = newMinutes; - onValueClick?.(`${lastValidHours.current}:${newMinutes}`); - }; + const handleMinutesClick = (newMinutes: number) => { + lastValidMinutes.current = newMinutes; + onValueClick?.(`${lastValidHours.current}:${newMinutes}`); + }; - return ( -
-
- -
-
- - generateDurationValues("minutes", lastValidHours.current) - } - /> -
-
- ); + return ( +
+
+ +
+
+ + generateDurationValues("minutes", lastValidHours.current) + } + /> +
+
+ ); }; diff --git a/src/components/Duration/components/__tests__/DurationValueList.test.tsx b/src/components/Duration/components/__tests__/DurationValueList.test.tsx index 7bf2b7d..59c135d 100644 --- a/src/components/Duration/components/__tests__/DurationValueList.test.tsx +++ b/src/components/Duration/components/__tests__/DurationValueList.test.tsx @@ -5,171 +5,166 @@ import type { DurationRangesResponse } from "@/services/api/rest/duration/types" // Mock hooks jest.mock("@/hooks/useDeviceDetection", () => ({ - useDeviceDetection: jest.fn(), + useDeviceDetection: jest.fn(), })); // Mock ScrollSelect component jest.mock("@/components/ui/scroll-select", () => ({ - ScrollSelect: ({ - options, - selectedValue, - onValueSelect, - onValueClick, - }: any) => ( -
- {options.map((option: any) => ( - - ))} -
- ), + ScrollSelect: ({ options, selectedValue, onValueSelect, onValueClick }: any) => ( +
+ {options.map((option: any) => ( + + ))} +
+ ), })); describe("DurationValueList", () => { - const mockOnValueSelect = jest.fn(); - const mockOnValueClick = jest.fn(); - const mockGetDurationValues = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - mockGetDurationValues.mockReturnValue([1, 2, 3]); - (useDeviceDetection as jest.Mock).mockReturnValue({ isDesktop: true }); - }); - - it("renders duration values with correct labels", () => { - // Test for hours - mockGetDurationValues.mockReturnValue([1, 2]); - render( - - ); - - expect(screen.getByText("1 hour")).toBeInTheDocument(); - expect(screen.getByText("2 hours")).toBeInTheDocument(); - - // Test for minutes - mockGetDurationValues.mockReturnValue([1, 2]); - render( - - ); - - expect(screen.getByText("1 minute")).toBeInTheDocument(); - expect(screen.getByText("2 minutes")).toBeInTheDocument(); - }); - - it("calls getDurationValues with correct type", () => { - render( - - ); - - expect(mockGetDurationValues).toHaveBeenCalledWith("hours"); - }); - - it("handles value selection", () => { - mockGetDurationValues.mockReturnValue([1, 2, 3]); - render( - - ); - - fireEvent.click(screen.getByTestId("option-2")); - - expect(mockOnValueSelect).toHaveBeenCalledWith(2); - expect(mockOnValueClick).toHaveBeenCalledWith(2); - }); - - it("passes correct auto-select prop based on device", () => { - // Test desktop - (useDeviceDetection as jest.Mock).mockReturnValue({ isDesktop: true }); - const { rerender } = render( - - ); - - expect(screen.getByTestId("mock-scroll-select")).toBeInTheDocument(); - - // Test mobile - (useDeviceDetection as jest.Mock).mockReturnValue({ isDesktop: false }); - rerender( - - ); - - expect(screen.getByTestId("mock-scroll-select")).toBeInTheDocument(); - }); - - it("formats unit labels correctly for all duration types", () => { - const testCases: Array<{ - type: keyof DurationRangesResponse; - singular: string; - plural: string; - }> = [ - { type: "ticks", singular: "tick", plural: "ticks" }, - { type: "seconds", singular: "second", plural: "seconds" }, - { type: "minutes", singular: "minute", plural: "minutes" }, - { type: "hours", singular: "hour", plural: "hours" }, - { type: "days", singular: "day", plural: "days" }, - ]; - - mockGetDurationValues.mockReturnValue([1, 2]); - - testCases.forEach(({ type, singular, plural }) => { - render( - - ); - - // Check singular form - expect(screen.getByText(`1 ${singular}`)).toBeInTheDocument(); - - // Check plural form - expect(screen.getByText(`2 ${plural}`)).toBeInTheDocument(); + const mockOnValueSelect = jest.fn(); + const mockOnValueClick = jest.fn(); + const mockGetDurationValues = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDurationValues.mockReturnValue([1, 2, 3]); + (useDeviceDetection as jest.Mock).mockReturnValue({ isDesktop: true }); + }); + + it("renders duration values with correct labels", () => { + // Test for hours + mockGetDurationValues.mockReturnValue([1, 2]); + render( + + ); + + expect(screen.getByText("1 hour")).toBeInTheDocument(); + expect(screen.getByText("2 hours")).toBeInTheDocument(); + + // Test for minutes + mockGetDurationValues.mockReturnValue([1, 2]); + render( + + ); + + expect(screen.getByText("1 minute")).toBeInTheDocument(); + expect(screen.getByText("2 minutes")).toBeInTheDocument(); + }); + + it("calls getDurationValues with correct type", () => { + render( + + ); + + expect(mockGetDurationValues).toHaveBeenCalledWith("hours"); + }); + + it("handles value selection", () => { + mockGetDurationValues.mockReturnValue([1, 2, 3]); + render( + + ); + + fireEvent.click(screen.getByTestId("option-2")); + + expect(mockOnValueSelect).toHaveBeenCalledWith(2); + expect(mockOnValueClick).toHaveBeenCalledWith(2); + }); + + it("passes correct auto-select prop based on device", () => { + // Test desktop + (useDeviceDetection as jest.Mock).mockReturnValue({ isDesktop: true }); + const { rerender } = render( + + ); + + expect(screen.getByTestId("mock-scroll-select")).toBeInTheDocument(); + + // Test mobile + (useDeviceDetection as jest.Mock).mockReturnValue({ isDesktop: false }); + rerender( + + ); + + expect(screen.getByTestId("mock-scroll-select")).toBeInTheDocument(); + }); + + it("formats unit labels correctly for all duration types", () => { + const testCases: Array<{ + type: keyof DurationRangesResponse; + singular: string; + plural: string; + }> = [ + { type: "ticks", singular: "tick", plural: "ticks" }, + { type: "seconds", singular: "second", plural: "seconds" }, + { type: "minutes", singular: "minute", plural: "minutes" }, + { type: "hours", singular: "hour", plural: "hours" }, + { type: "days", singular: "day", plural: "days" }, + ]; + + mockGetDurationValues.mockReturnValue([1, 2]); + + testCases.forEach(({ type, singular, plural }) => { + render( + + ); + + // Check singular form + expect(screen.getByText(`1 ${singular}`)).toBeInTheDocument(); + + // Check plural form + expect(screen.getByText(`2 ${plural}`)).toBeInTheDocument(); + }); }); - }); }); diff --git a/src/components/Duration/components/__tests__/HoursDurationValue.test.tsx b/src/components/Duration/components/__tests__/HoursDurationValue.test.tsx index 6cdbfbf..4a30e9a 100644 --- a/src/components/Duration/components/__tests__/HoursDurationValue.test.tsx +++ b/src/components/Duration/components/__tests__/HoursDurationValue.test.tsx @@ -4,137 +4,130 @@ import { generateDurationValues } from "@/utils/duration"; // Mock DurationValueList component jest.mock("../DurationValueList", () => ({ - DurationValueList: ({ - selectedValue, - onValueSelect, - onValueClick, - durationType, - }: any) => { - const value = selectedValue; - return ( -
{ - onValueSelect(value); - onValueClick?.(value); - }} - > - {value} -
- ); - }, + DurationValueList: ({ selectedValue, onValueSelect, onValueClick, durationType }: any) => { + const value = selectedValue; + return ( +
{ + onValueSelect(value); + onValueClick?.(value); + }} + > + {value} +
+ ); + }, })); jest.mock("@/utils/duration", () => ({ - generateDurationValues: jest.fn(), - getSpecialCaseKey: jest.fn().mockReturnValue("key"), + generateDurationValues: jest.fn(), + getSpecialCaseKey: jest.fn().mockReturnValue("key"), })); describe("HoursDurationValue", () => { - const mockOnValueSelect = jest.fn(); - const mockOnValueClick = jest.fn(); - const mockIsInitialRender = { current: true }; - - beforeEach(() => { - jest.clearAllMocks(); - // Mock generateDurationValues to return test values - (generateDurationValues as jest.Mock).mockImplementation((type) => { - if (type === "hours") return [1, 2, 3]; - if (type === "minutes") return [0, 15, 30, 45]; - return []; + const mockOnValueSelect = jest.fn(); + const mockOnValueClick = jest.fn(); + const mockIsInitialRender = { current: true }; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock generateDurationValues to return test values + (generateDurationValues as jest.Mock).mockImplementation((type) => { + if (type === "hours") return [1, 2, 3]; + if (type === "minutes") return [0, 15, 30, 45]; + return []; + }); + }); + + it("renders with initial selected value", () => { + render( + + ); + + // Check if component renders with proper structure and both lists + expect( + screen.getByRole("group", { name: /duration in hours and minutes/i }) + ).toBeInTheDocument(); + + const [hoursList, minutesList] = screen.getAllByTestId("mock-duration-value-list"); + + expect(hoursList).toHaveAttribute("data-type", "hours"); + expect(minutesList).toHaveAttribute("data-type", "minutes"); + }); + + it("maintains accessibility attributes", () => { + render( + + ); + + // Verify ARIA attributes + expect(screen.getByRole("group")).toHaveAttribute( + "aria-label", + "Duration in hours and minutes" + ); + }); + + it("keeps minutes value during initial render", () => { + render( + + ); + + const [hoursList] = screen.getAllByTestId("mock-duration-value-list"); + expect(hoursList).toHaveAttribute("data-type", "hours"); + fireEvent.click(hoursList); + + expect(mockOnValueSelect).toHaveBeenCalledWith("2:30"); + }); + + it("resets minutes to 00 after initial render", () => { + mockIsInitialRender.current = false; + render( + + ); + + const [hoursList] = screen.getAllByTestId("mock-duration-value-list"); + expect(hoursList).toHaveAttribute("data-type", "hours"); + fireEvent.click(hoursList); + + expect(mockOnValueSelect).toHaveBeenCalledWith("2:00"); + }); + + it("updates minutes value when minutes are selected", () => { + render( + + ); + + const [, minutesList] = screen.getAllByTestId("mock-duration-value-list"); + expect(minutesList).toHaveAttribute("data-type", "minutes"); + fireEvent.click(minutesList); + + expect(mockOnValueSelect).toHaveBeenCalledWith("2:30"); }); - }); - - it("renders with initial selected value", () => { - render( - - ); - - // Check if component renders with proper structure and both lists - expect( - screen.getByRole("group", { name: /duration in hours and minutes/i }) - ).toBeInTheDocument(); - - const [hoursList, minutesList] = screen.getAllByTestId( - "mock-duration-value-list" - ); - - expect(hoursList).toHaveAttribute("data-type", "hours"); - expect(minutesList).toHaveAttribute("data-type", "minutes"); - }); - - it("maintains accessibility attributes", () => { - render( - - ); - - // Verify ARIA attributes - expect(screen.getByRole("group")).toHaveAttribute( - "aria-label", - "Duration in hours and minutes" - ); - }); - - it("keeps minutes value during initial render", () => { - render( - - ); - - const [hoursList] = screen.getAllByTestId("mock-duration-value-list"); - expect(hoursList).toHaveAttribute("data-type", "hours"); - fireEvent.click(hoursList); - - expect(mockOnValueSelect).toHaveBeenCalledWith("2:30"); - }); - - it("resets minutes to 00 after initial render", () => { - mockIsInitialRender.current = false; - render( - - ); - - const [hoursList] = screen.getAllByTestId("mock-duration-value-list"); - expect(hoursList).toHaveAttribute("data-type", "hours"); - fireEvent.click(hoursList); - - expect(mockOnValueSelect).toHaveBeenCalledWith("2:00"); - }); - - it("updates minutes value when minutes are selected", () => { - render( - - ); - - const [, minutesList] = screen.getAllByTestId("mock-duration-value-list"); - expect(minutesList).toHaveAttribute("data-type", "minutes"); - fireEvent.click(minutesList); - - expect(mockOnValueSelect).toHaveBeenCalledWith("2:30"); - }); }); diff --git a/src/components/HowToTrade/GuideModal.tsx b/src/components/HowToTrade/GuideModal.tsx index baf28bf..1caebbb 100644 --- a/src/components/HowToTrade/GuideModal.tsx +++ b/src/components/HowToTrade/GuideModal.tsx @@ -1,16 +1,21 @@ import { Modal } from "@/components/ui/modal"; import { guideConfig } from "@/config/guideConfig"; import { TabList } from "../ui/tab-list"; +import { TradeType } from "@/config/tradeTypes"; interface GuideProps { isOpen: boolean; onClose: () => void; - type?: string; + type?: TradeType; } -const Guides = [{ label: "Rise/Fall", value: "rise-fall" }]; +const Guides = [{ label: "Rise/Fall", value: "rise_fall" }]; -export const GuideModal = ({ isOpen, onClose, type = "rise-fall" }: GuideProps) => { +export const GuideModal = ({ + isOpen, + onClose, + type = "rise_fall", +}: GuideProps) => { const content = guideConfig[type]?.body; if (!content) { @@ -26,7 +31,7 @@ export const GuideModal = ({ isOpen, onClose, type = "rise-fall" }: GuideProps) value} /> } diff --git a/src/components/HowToTrade/HowToTrade.tsx b/src/components/HowToTrade/HowToTrade.tsx index 451252e..9046bc7 100644 --- a/src/components/HowToTrade/HowToTrade.tsx +++ b/src/components/HowToTrade/HowToTrade.tsx @@ -3,11 +3,13 @@ import { useBottomSheetStore } from "@/stores/bottomSheetStore"; import { useDeviceDetection } from "@/hooks/useDeviceDetection"; import { GuideModal } from "./GuideModal"; import { ChevronRight } from "lucide-react"; +import { useTradeStore } from "@/stores/tradeStore"; export const HowToTrade: React.FC = () => { const { setBottomSheet } = useBottomSheetStore(); const { isDesktop } = useDeviceDetection(); const [isModalOpen, setIsModalOpen] = useState(false); + const { tradeTypeDisplayName } = useTradeStore(); const handleClick = () => { if (isDesktop) { @@ -27,7 +29,8 @@ export const HowToTrade: React.FC = () => { onClick={handleClick} className="text-gray-500 hover:text-gray-600 text-sm flex items-center gap-1" > - How to trade Rise/Fall? + How to trade {tradeTypeDisplayName} + ? @@ -35,7 +38,7 @@ export const HowToTrade: React.FC = () => { setIsModalOpen(false)} - type="rise-fall" + type="rise_fall" /> )} diff --git a/src/components/HowToTrade/__tests__/HowToTrade.test.tsx b/src/components/HowToTrade/__tests__/HowToTrade.test.tsx index cde6893..f26f30d 100644 --- a/src/components/HowToTrade/__tests__/HowToTrade.test.tsx +++ b/src/components/HowToTrade/__tests__/HowToTrade.test.tsx @@ -10,6 +10,10 @@ jest.mock("@/stores/bottomSheetStore", () => ({ }), })); +jest.mock("@/stores/tradeStore", () => ({ + useTradeStore: jest.fn(() => ({ tradeTypeDisplayName: "Rise/Fall" })), +})); + describe("HowToTrade", () => { beforeEach(() => { mockSetBottomSheet.mockClear(); diff --git a/src/components/MarketInfo/MarketInfo.tsx b/src/components/MarketInfo/MarketInfo.tsx index b14727c..fdced9f 100644 --- a/src/components/MarketInfo/MarketInfo.tsx +++ b/src/components/MarketInfo/MarketInfo.tsx @@ -21,11 +21,13 @@ export const MarketInfo: React.FC = ({ if (isMobile) { return (
-
+
{selectedMarket && (
{ const location = useLocation(); const { isLoggedIn } = useClientStore(); const { isLandscape } = useOrientationStore(); - const { activeSidebar, toggleSidebar, isSideNavVisible } = useMainLayoutStore(); + const { activeSidebar, toggleSidebar, isSideNavVisible } = + useMainLayoutStore(); return ( ); diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index a9dc54f..a444983 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -7,16 +7,24 @@ interface SidebarProps { children: React.ReactNode; } -export const Sidebar: React.FC = ({ isOpen, onClose, title, children }) => { +export const Sidebar: React.FC = ({ + isOpen, + onClose, + title, + children, +}) => { return (

{title}

-
diff --git a/src/components/Stake/StakeController.tsx b/src/components/Stake/StakeController.tsx index 4150e39..c0ce2ff 100644 --- a/src/components/Stake/StakeController.tsx +++ b/src/components/Stake/StakeController.tsx @@ -15,10 +15,10 @@ import { tradeTypeConfigs } from "@/config/tradeTypes"; import { useOrientationStore } from "@/stores/orientationStore"; interface ButtonState { - loading: boolean; - error: Event | null; - payout: number; - reconnecting?: boolean; + loading: boolean; + error: Event | null; + payout: number; + reconnecting?: boolean; } type ButtonStates = Record; @@ -26,236 +26,232 @@ type ButtonStates = Record; interface StakeControllerProps {} export const StakeController: React.FC = () => { - const { stake, setStake, trade_type, duration, payouts, setPayouts } = - useTradeStore(); - const { currency, token } = useClientStore(); - const { isLandscape } = useOrientationStore(); - const { setBottomSheet } = useBottomSheetStore(); + const { stake, setStake, trade_type, duration, payouts, setPayouts } = useTradeStore(); + const { currency, token } = useClientStore(); + const { isLandscape } = useOrientationStore(); + const { setBottomSheet } = useBottomSheetStore(); - const [localStake, setLocalStake] = React.useState(stake); - const [debouncedStake, setDebouncedStake] = React.useState(stake); - const [error, setError] = React.useState(false); - const [errorMessage, setErrorMessage] = React.useState(); - const [buttonStates, setButtonStates] = useState(() => { - const initialStates: ButtonStates = {}; - tradeTypeConfigs[trade_type].buttons.forEach((button) => { - initialStates[button.actionName] = { - loading: true, - error: null, - payout: 0, - reconnecting: false, - }; + const [localStake, setLocalStake] = React.useState(stake); + const [debouncedStake, setDebouncedStake] = React.useState(stake); + const [error, setError] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(); + const [buttonStates, setButtonStates] = useState(() => { + const initialStates: ButtonStates = {}; + tradeTypeConfigs[trade_type].buttons.forEach((button) => { + initialStates[button.actionName] = { + loading: true, + error: null, + payout: 0, + reconnecting: false, + }; + }); + return initialStates; }); - return initialStates; - }); - // Parse duration for API call - const { value: apiDurationValue, type: apiDurationType } = - parseDuration(duration); + // Parse duration for API call + const { value: apiDurationValue, type: apiDurationType } = parseDuration(duration); - // Debounce stake updates - useDebounce(localStake, setDebouncedStake, 500); + // Debounce stake updates + useDebounce(localStake, setDebouncedStake, 500); - useEffect(() => { - // Create SSE connections for each button's contract type - const cleanupFunctions = tradeTypeConfigs[trade_type].buttons.map( - (button) => { - return createSSEConnection({ - params: { - action: "contract_price", - duration: formatDuration(Number(apiDurationValue), apiDurationType), - trade_type: button.contractType, - instrument: "R_100", - currency: currency, - payout: stake, - strike: stake, - }, - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - onMessage: (priceData) => { - // Update button state for this specific button - setButtonStates((prev) => ({ - ...prev, - [button.actionName]: { - loading: false, - error: null, - payout: Number(priceData.price), - reconnecting: false, - }, - })); + useEffect(() => { + // Create SSE connections for each button's contract type + const cleanupFunctions = tradeTypeConfigs[trade_type].buttons.map((button) => { + return createSSEConnection({ + params: { + action: "contract_price", + duration: formatDuration(Number(apiDurationValue), apiDurationType), + trade_type: button.contractType, + instrument: "R_100", + currency: currency, + payout: stake, + strike: stake, + }, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + onMessage: (priceData) => { + // Update button state for this specific button + setButtonStates((prev) => ({ + ...prev, + [button.actionName]: { + loading: false, + error: null, + payout: Number(priceData.price), + reconnecting: false, + }, + })); - // Update payouts in store - const payoutValue = Number(priceData.price); + // Update payouts in store + const payoutValue = Number(priceData.price); - // Create a map of button action names to their payout values - const payoutValues = Object.keys(buttonStates).reduce( - (acc, key) => { - acc[key] = - key === button.actionName - ? payoutValue - : buttonStates[key]?.payout || 0; - return acc; - }, - {} as Record - ); + // Create a map of button action names to their payout values + const payoutValues = Object.keys(buttonStates).reduce( + (acc, key) => { + acc[key] = + key === button.actionName + ? payoutValue + : buttonStates[key]?.payout || 0; + return acc; + }, + {} as Record + ); - setPayouts({ - max: 50000, - values: payoutValues, + setPayouts({ + max: 50000, + values: payoutValues, + }); + }, + onError: (error) => { + setButtonStates((prev) => ({ + ...prev, + [button.actionName]: { + ...prev[button.actionName], + loading: false, + error, + reconnecting: true, + }, + })); + }, + onOpen: () => { + setButtonStates((prev) => ({ + ...prev, + [button.actionName]: { + ...prev[button.actionName], + error: null, + reconnecting: false, + }, + })); + }, }); - }, - onError: (error) => { - setButtonStates((prev) => ({ - ...prev, - [button.actionName]: { - ...prev[button.actionName], - loading: false, - error, - reconnecting: true, - }, - })); - }, - onOpen: () => { - setButtonStates((prev) => ({ - ...prev, - [button.actionName]: { - ...prev[button.actionName], - error: null, - reconnecting: false, - }, - })); - }, }); - } - ); - return () => { - cleanupFunctions.forEach((cleanup) => cleanup()); - }; - }, [duration, stake, currency, token]); + return () => { + cleanupFunctions.forEach((cleanup) => cleanup()); + }; + }, [duration, stake, currency, token]); - const validateAndUpdateStake = (value: string) => { - if (!value) { - setError(true); - setErrorMessage("Please enter an amount"); - return { error: true }; - } + const validateAndUpdateStake = (value: string) => { + if (!value) { + setError(true); + setErrorMessage("Please enter an amount"); + return { error: true }; + } - const amount = parseStakeAmount(value); - const validation = validateStake({ - amount, - minStake: getStakeConfig().min, - maxStake: payouts.max, - currency, - }); + const amount = parseStakeAmount(value); + const validation = validateStake({ + amount, + minStake: getStakeConfig().min, + maxStake: payouts.max, + currency, + }); - setError(validation.error); - setErrorMessage(validation.message); + setError(validation.error); + setErrorMessage(validation.message); - return validation; - }; + return validation; + }; - const validateStakeOnly = (value: string) => { - if (!value) { - setError(true); - setErrorMessage("Please enter an amount"); - return { error: true }; - } + const validateStakeOnly = (value: string) => { + if (!value) { + setError(true); + setErrorMessage("Please enter an amount"); + return { error: true }; + } - const amount = parseStakeAmount(value); - const validation = validateStake({ - amount, - minStake: getStakeConfig().min, - maxStake: payouts.max, - currency, - }); + const amount = parseStakeAmount(value); + const validation = validateStake({ + amount, + minStake: getStakeConfig().min, + maxStake: payouts.max, + currency, + }); - setError(validation.error); - setErrorMessage(validation.message); - return validation; - }; + setError(validation.error); + setErrorMessage(validation.message); + return validation; + }; - const preventExceedingMax = (value: string) => { - if (error && errorMessage?.includes("maximum")) { - const newAmount = value ? parseStakeAmount(value) : 0; - const maxAmount = parseStakeAmount(payouts.max.toString()); - return newAmount > maxAmount; - } - return false; - }; + const preventExceedingMax = (value: string) => { + if (error && errorMessage?.includes("maximum")) { + const newAmount = value ? parseStakeAmount(value) : 0; + const maxAmount = parseStakeAmount(payouts.max.toString()); + return newAmount > maxAmount; + } + return false; + }; - const handleStakeChange = (value: string) => { - if (preventExceedingMax(value)) return; + const handleStakeChange = (value: string) => { + if (preventExceedingMax(value)) return; - if (isLandscape) { - setLocalStake(value); - validateStakeOnly(value); - return; - } + if (isLandscape) { + setLocalStake(value); + validateStakeOnly(value); + return; + } - setLocalStake(value); - validateAndUpdateStake(value); - }; + setLocalStake(value); + validateAndUpdateStake(value); + }; - useEffect(() => { - if (!isLandscape) return; + useEffect(() => { + if (!isLandscape) return; - if (debouncedStake !== stake) { - const validation = validateStakeOnly(debouncedStake); - if (!validation.error) { - setStake(debouncedStake); - } - } - }, [isLandscape, debouncedStake, stake]); + if (debouncedStake !== stake) { + const validation = validateStakeOnly(debouncedStake); + if (!validation.error) { + setStake(debouncedStake); + } + } + }, [isLandscape, debouncedStake, stake]); - const handleSave = () => { - if (isLandscape) return; + const handleSave = () => { + if (isLandscape) return; - const validation = validateAndUpdateStake(localStake); - if (validation.error) return; + const validation = validateAndUpdateStake(localStake); + if (validation.error) return; - setStake(localStake); - setBottomSheet(false); - }; + setStake(localStake); + setBottomSheet(false); + }; - const content = ( - <> - {!isLandscape && } -
- state.loading)} - loadingStates={Object.keys(buttonStates).reduce( - (acc, key) => ({ - ...acc, - [key]: buttonStates[key].loading, - }), - {} - )} - /> -
- {!isLandscape && ( -
- - Save - -
- )} - - ); + const content = ( + <> + {!isLandscape && } +
+ state.loading)} + loadingStates={Object.keys(buttonStates).reduce( + (acc, key) => ({ + ...acc, + [key]: buttonStates[key].loading, + }), + {} + )} + /> +
+ {!isLandscape && ( +
+ + Save + +
+ )} + + ); - if (isLandscape) { - return
{content}
; - } + if (isLandscape) { + return
{content}
; + } - return
{content}
; + return
{content}
; }; diff --git a/src/components/Stake/StakeField.tsx b/src/components/Stake/StakeField.tsx index 7fc7184..e1a9f48 100644 --- a/src/components/Stake/StakeField.tsx +++ b/src/components/Stake/StakeField.tsx @@ -7,121 +7,121 @@ import { useOrientationStore } from "@/stores/orientationStore"; import { DesktopTradeFieldCard } from "@/components/ui/desktop-trade-field-card"; interface StakeFieldProps { - className?: string; + className?: string; } export const StakeField: React.FC = ({ className }) => { - const { isLandscape } = useOrientationStore(); - const { - stake, - currency, - error, - errorMessage, - localValue, - inputRef, - containerRef, - handleSelect, - handleChange, - handleIncrement, - handleDecrement, - handleMobileClick, - isConfigLoading, - isStakeSelected, - } = useStakeField(); + const { isLandscape } = useOrientationStore(); + const { + stake, + currency, + error, + errorMessage, + localValue, + inputRef, + containerRef, + handleSelect, + handleChange, + handleIncrement, + handleDecrement, + handleMobileClick, + isConfigLoading, + isStakeSelected, + } = useStakeField(); - if (isConfigLoading) { - return ( -
-
-
- ); - } - - if (!isLandscape) { - return ( -
-
- -
- {error && errorMessage && ( -
- - {errorMessage} - -
- )} -
- ); - } - - return ( -
- -
handleSelect(true)} - onBlur={(e) => { - // Only blur if we're not clicking inside the component - if (!e.currentTarget.contains(e.relatedTarget)) { - handleSelect(false); - } - }} - tabIndex={0} - > -
-
- - Stake ({currency}) - -
- handleSelect(true)} - className="text-left font-ibm-plex text-base leading-6 font-normal bg-transparent w-24 outline-none text-gray-900" - aria-label="Stake amount" + if (isConfigLoading) { + return ( +
+
-
-
-
- -
-
- -
+ +
+ {error && errorMessage && ( +
+ + {errorMessage} + +
+ )}
-
- + ); + } + + return ( +
+ +
handleSelect(true)} + onBlur={(e) => { + // Only blur if we're not clicking inside the component + if (!e.currentTarget.contains(e.relatedTarget)) { + handleSelect(false); + } + }} + tabIndex={0} + > +
+
+ + Stake ({currency}) + +
+ handleSelect(true)} + className="text-left font-ibm-plex text-base leading-6 font-normal bg-transparent w-24 outline-none text-gray-900" + aria-label="Stake amount" + /> +
+
+
+
+ +
+
+ +
+
+
+ +
+
- -
- ); + ); }; diff --git a/src/components/Stake/__tests__/StakeField.test.tsx b/src/components/Stake/__tests__/StakeField.test.tsx index 1693475..6dfd46a 100644 --- a/src/components/Stake/__tests__/StakeField.test.tsx +++ b/src/components/Stake/__tests__/StakeField.test.tsx @@ -8,218 +8,193 @@ import { useTooltipStore } from "@/stores/tooltipStore"; // Mock components jest.mock("@/components/ui/tooltip", () => ({ - Tooltip: () =>
, + Tooltip: () =>
, })); jest.mock("@/components/ui/desktop-trade-field-card", () => ({ - DesktopTradeFieldCard: ({ children, isSelected, error }: any) => ( -
- {children} -
- ), + DesktopTradeFieldCard: ({ children, isSelected, error }: any) => ( +
+ {children} +
+ ), })); jest.mock("@/components/TradeFields/TradeParam", () => ({ - __esModule: true, - default: ({ label, value, onClick }: any) => ( - - ), + __esModule: true, + default: ({ label, value, onClick }: any) => ( + + ), })); // Mock stores jest.mock("@/stores/tradeStore", () => ({ - useTradeStore: jest.fn(), + useTradeStore: jest.fn(), })); jest.mock("@/stores/orientationStore", () => ({ - useOrientationStore: jest.fn(), + useOrientationStore: jest.fn(), })); jest.mock("@/stores/clientStore", () => ({ - useClientStore: jest.fn(), + useClientStore: jest.fn(), })); jest.mock("@/stores/bottomSheetStore", () => ({ - useBottomSheetStore: jest.fn(), + useBottomSheetStore: jest.fn(), })); jest.mock("@/stores/tooltipStore", () => ({ - useTooltipStore: jest.fn(), + useTooltipStore: jest.fn(), })); describe("StakeField", () => { - const mockShowTooltip = jest.fn(); - const mockHideTooltip = jest.fn(); - const mockSetBottomSheet = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - // Default store mocks - ( - useTooltipStore as jest.MockedFunction - ).mockReturnValue({ - showTooltip: mockShowTooltip, - hideTooltip: mockHideTooltip, - }); - ( - useTradeStore as jest.MockedFunction - ).mockReturnValue({ - stake: "100", - setStake: jest.fn(), - isConfigLoading: false, - }); - - ( - useOrientationStore as jest.MockedFunction - ).mockReturnValue({ - isLandscape: false, - }); - - ( - useClientStore as jest.MockedFunction - ).mockReturnValue({ - currency: "USD", - }); - - ( - useBottomSheetStore as jest.MockedFunction - ).mockReturnValue({ - setBottomSheet: mockSetBottomSheet, - }); - }); - - describe("Loading State", () => { - it("should show skeleton loader when config is loading", () => { - ( - useTradeStore as jest.MockedFunction - ).mockReturnValue({ - stake: "100", - setStake: jest.fn(), - isConfigLoading: true, - }); - - render(); - - const skeleton = screen.getByTestId("stake-field-skeleton"); - expect(skeleton).toBeInTheDocument(); - expect(screen.queryByTestId("trade-param")).not.toBeInTheDocument(); - }); - - it("should show stake value when not loading", () => { - render(); - - const param = screen.getByTestId("trade-param"); - expect(param).toBeInTheDocument(); - expect(param).toHaveAttribute("data-label", "Stake"); - expect(param).toHaveAttribute("data-value", "100 USD"); - expect( - screen.queryByTestId("stake-field-skeleton") - ).not.toBeInTheDocument(); - }); - }); - - describe("Portrait Mode", () => { - beforeEach(() => { - ( - useOrientationStore as jest.MockedFunction - ).mockReturnValue({ - isLandscape: false, - }); - }); - - it("should open bottom sheet when clicked", () => { - render(); - - fireEvent.click(screen.getByTestId("trade-param")); + const mockShowTooltip = jest.fn(); + const mockHideTooltip = jest.fn(); + const mockSetBottomSheet = jest.fn(); - expect(mockSetBottomSheet).toHaveBeenCalledWith(true, "stake", "400px"); - }); - }); - - describe("Landscape Mode", () => { beforeEach(() => { - ( - useOrientationStore as jest.MockedFunction - ).mockReturnValue({ - isLandscape: true, - }); + jest.clearAllMocks(); + + // Default store mocks + (useTooltipStore as jest.MockedFunction).mockReturnValue({ + showTooltip: mockShowTooltip, + hideTooltip: mockHideTooltip, + }); + (useTradeStore as jest.MockedFunction).mockReturnValue({ + stake: "100", + setStake: jest.fn(), + isConfigLoading: false, + }); + + (useOrientationStore as jest.MockedFunction).mockReturnValue({ + isLandscape: false, + }); + + (useClientStore as jest.MockedFunction).mockReturnValue({ + currency: "USD", + }); + + (useBottomSheetStore as jest.MockedFunction).mockReturnValue({ + setBottomSheet: mockSetBottomSheet, + }); }); - it("should show input field and increment/decrement buttons", () => { - render(); - - expect(screen.getByLabelText("Stake amount")).toBeInTheDocument(); - expect(screen.getByLabelText("Decrease stake")).toBeInTheDocument(); - expect(screen.getByLabelText("Increase stake")).toBeInTheDocument(); + describe("Loading State", () => { + it("should show skeleton loader when config is loading", () => { + (useTradeStore as jest.MockedFunction).mockReturnValue({ + stake: "100", + setStake: jest.fn(), + isConfigLoading: true, + }); + + render(); + + const skeleton = screen.getByTestId("stake-field-skeleton"); + expect(skeleton).toBeInTheDocument(); + expect(screen.queryByTestId("trade-param")).not.toBeInTheDocument(); + }); + + it("should show stake value when not loading", () => { + render(); + + const param = screen.getByTestId("trade-param"); + expect(param).toBeInTheDocument(); + expect(param).toHaveAttribute("data-label", "Stake"); + expect(param).toHaveAttribute("data-value", "100 USD"); + expect(screen.queryByTestId("stake-field-skeleton")).not.toBeInTheDocument(); + }); }); - it("should update isSelected state when clicked", () => { - render(); + describe("Portrait Mode", () => { + beforeEach(() => { + ( + useOrientationStore as jest.MockedFunction + ).mockReturnValue({ + isLandscape: false, + }); + }); - const container = screen.getByTestId("desktop-trade-field-card") - .firstChild as HTMLElement; - expect(container.parentElement).toHaveAttribute("data-selected", "false"); + it("should open bottom sheet when clicked", () => { + render(); - fireEvent.click(container); - expect(container.parentElement).toHaveAttribute("data-selected", "true"); + fireEvent.click(screen.getByTestId("trade-param")); - // Blur should unselect - fireEvent.blur(container); - expect(container.parentElement).toHaveAttribute("data-selected", "false"); + expect(mockSetBottomSheet).toHaveBeenCalledWith(true, "stake", "400px"); + }); }); - it("should handle increment/decrement with validation", () => { - const mockSetStake = jest.fn(); - ( - useTradeStore as jest.MockedFunction - ).mockReturnValue({ - stake: "100", - setStake: mockSetStake, - isConfigLoading: false, - }); - - render(); - - // Test increment (step = 1) - fireEvent.click(screen.getByLabelText("Increase stake")); - expect(mockSetStake).toHaveBeenCalledWith("101"); // 100 + 1 - - // Test decrement (step = 1) - fireEvent.click(screen.getByLabelText("Decrease stake")); - expect(mockSetStake).toHaveBeenCalledWith("99"); // 100 - 1 - }); - - it("should show error state and tooltip when validation fails", () => { - render(); - - const input = screen.getByLabelText("Stake amount"); - fireEvent.change(input, { target: { value: "" } }); // Empty value triggers error - - const card = screen.getByTestId("desktop-trade-field-card"); - expect(card).toHaveAttribute("data-error", "true"); - - // Verify tooltip is shown with error message - expect(mockShowTooltip).toHaveBeenCalledWith( - "Please enter an amount", - expect.any(Object), - "error" - ); - - // Verify tooltip is hidden when valid value is entered - fireEvent.change(input, { target: { value: "100" } }); - expect(mockHideTooltip).toHaveBeenCalled(); + describe("Landscape Mode", () => { + beforeEach(() => { + ( + useOrientationStore as jest.MockedFunction + ).mockReturnValue({ + isLandscape: true, + }); + }); + + it("should show input field and increment/decrement buttons", () => { + render(); + + expect(screen.getByLabelText("Stake amount")).toBeInTheDocument(); + expect(screen.getByLabelText("Decrease stake")).toBeInTheDocument(); + expect(screen.getByLabelText("Increase stake")).toBeInTheDocument(); + }); + + it("should update isSelected state when clicked", () => { + render(); + + const container = screen.getByTestId("desktop-trade-field-card") + .firstChild as HTMLElement; + expect(container.parentElement).toHaveAttribute("data-selected", "false"); + + fireEvent.click(container); + expect(container.parentElement).toHaveAttribute("data-selected", "true"); + + // Blur should unselect + fireEvent.blur(container); + expect(container.parentElement).toHaveAttribute("data-selected", "false"); + }); + + it("should handle increment/decrement with validation", () => { + const mockSetStake = jest.fn(); + (useTradeStore as jest.MockedFunction).mockReturnValue({ + stake: "100", + setStake: mockSetStake, + isConfigLoading: false, + }); + + render(); + + // Test increment (step = 1) + fireEvent.click(screen.getByLabelText("Increase stake")); + expect(mockSetStake).toHaveBeenCalledWith("101"); // 100 + 1 + + // Test decrement (step = 1) + fireEvent.click(screen.getByLabelText("Decrease stake")); + expect(mockSetStake).toHaveBeenCalledWith("99"); // 100 - 1 + }); + + it("should show error state and tooltip when validation fails", () => { + render(); + + const input = screen.getByLabelText("Stake amount"); + fireEvent.change(input, { target: { value: "" } }); // Empty value triggers error + + const card = screen.getByTestId("desktop-trade-field-card"); + expect(card).toHaveAttribute("data-error", "true"); + + // Verify tooltip is shown with error message + expect(mockShowTooltip).toHaveBeenCalledWith( + "Please enter an amount", + expect.any(Object), + "error" + ); + + // Verify tooltip is hidden when valid value is entered + fireEvent.change(input, { target: { value: "100" } }); + expect(mockHideTooltip).toHaveBeenCalled(); + }); }); - }); }); diff --git a/src/components/Stake/components/StakeInput.tsx b/src/components/Stake/components/StakeInput.tsx index 56f73a3..d36b404 100644 --- a/src/components/Stake/components/StakeInput.tsx +++ b/src/components/Stake/components/StakeInput.tsx @@ -6,124 +6,120 @@ import { incrementStake, decrementStake } from "@/utils/stake"; import { useClientStore } from "@/stores/clientStore"; interface StakeInputProps { - value: string; - onChange: (value: string) => void; - onBlur?: () => void; - isDesktop?: boolean; - error?: boolean; - errorMessage?: string; - maxPayout?: number; + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + isDesktop?: boolean; + error?: boolean; + errorMessage?: string; + maxPayout?: number; } export const StakeInput: React.FC = ({ - value, - onChange, - onBlur, - isDesktop, - error, - errorMessage, - maxPayout, + value, + onChange, + onBlur, + isDesktop, + error, + errorMessage, + maxPayout, }) => { - const { currency } = useClientStore(); + const { currency } = useClientStore(); - const handleIncrement = () => { - onChange(incrementStake(value || "0")); - }; + const handleIncrement = () => { + onChange(incrementStake(value || "0")); + }; - const handleDecrement = () => { - onChange(decrementStake(value || "0")); - }; + const handleDecrement = () => { + onChange(decrementStake(value || "0")); + }; - const handleChange = (e: React.ChangeEvent) => { - const numericValue = e.target.value.replace(/[^0-9.]/g, ""); - const currentAmount = value ? value.split(" ")[0] : ""; - const amount = parseFloat(numericValue); + const handleChange = (e: React.ChangeEvent) => { + const numericValue = e.target.value.replace(/[^0-9.]/g, ""); + const currentAmount = value ? value.split(" ")[0] : ""; + const amount = parseFloat(numericValue); - // Only prevent adding more numbers if there's a max error - if ( - error && - maxPayout && - amount > maxPayout && - e.target.value.length > currentAmount.length - ) { - return; - } + // Only prevent adding more numbers if there's a max error + if ( + error && + maxPayout && + amount > maxPayout && + e.target.value.length > currentAmount.length + ) { + return; + } - if (numericValue === "") { - onChange(""); - return; - } + if (numericValue === "") { + onChange(""); + return; + } - if (!isNaN(amount)) { - onChange(amount.toString()); - } - }; + if (!isNaN(amount)) { + onChange(amount.toString()); + } + }; - const amount = value ? value.split(" ")[0] : ""; + const amount = value ? value.split(" ")[0] : ""; - const inputRef = useRef(null); + const inputRef = useRef(null); - useEffect(() => { - // Focus input when component mounts - inputRef.current?.focus(); - }, []); + useEffect(() => { + // Focus input when component mounts + inputRef.current?.focus(); + }, []); - return ( -
- {isDesktop ? ( - <> - - - − - - - } - rightIcon={ - - } - type="text" - inputMode="decimal" - aria-label="Stake amount" - /> - - ) : ( - - )} -
- ); + return ( +
+ {isDesktop ? ( + <> + + − + + } + rightIcon={ + + } + type="text" + inputMode="decimal" + aria-label="Stake amount" + /> + + ) : ( + + )} +
+ ); }; diff --git a/src/components/Stake/hooks/useStakeField.ts b/src/components/Stake/hooks/useStakeField.ts index 9ef23f6..b60c0c7 100644 --- a/src/components/Stake/hooks/useStakeField.ts +++ b/src/components/Stake/hooks/useStakeField.ts @@ -1,163 +1,155 @@ import { useState, useRef, useEffect } from "react"; import { useTradeStore } from "@/stores/tradeStore"; import { useClientStore } from "@/stores/clientStore"; -import { - incrementStake, - decrementStake, - parseStakeAmount, -} from "@/utils/stake"; +import { incrementStake, decrementStake, parseStakeAmount } from "@/utils/stake"; import { getStakeConfig } from "@/adapters/stake-config-adapter"; import { useBottomSheetStore } from "@/stores/bottomSheetStore"; import { useTooltipStore } from "@/stores/tooltipStore"; import { validateStake } from "../utils/validation"; export const useStakeField = () => { - const { stake, setStake, isConfigLoading } = useTradeStore(); - const { currency } = useClientStore(); - const { setBottomSheet } = useBottomSheetStore(); - const { showTooltip, hideTooltip } = useTooltipStore(); - const [isStakeSelected, setIsStakeSelected] = useState(false); - const [error, setError] = useState(false); - const [errorMessage, setErrorMessage] = useState(); - const [localValue, setLocalValue] = useState(stake); - const inputRef = useRef(null); - const containerRef = useRef(null); - - useEffect(() => { - setLocalValue(stake); - }, [stake]); - - const showError = (message: string) => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - showTooltip( - message, - { x: rect.left - 8, y: rect.top + rect.height / 2 }, - "error" - ); - } - }; - - const validateAndUpdateStake = (value: string) => { - const amount = parseStakeAmount(value || "0"); - const validation = validateStake({ - amount, - minStake: getStakeConfig().min, - maxStake: getStakeConfig().max, - currency, - }); - - setError(validation.error); - setErrorMessage(validation.message); - if (validation.error && validation.message) { - showError(validation.message); - } - return !validation.error; - }; - - const handleIncrement = () => { - const newValue = incrementStake(stake || "0"); - if (validateAndUpdateStake(newValue)) { - setStake(newValue); - hideTooltip(); - } - }; - - const handleDecrement = () => { - const newValue = decrementStake(stake || "0"); - if (validateAndUpdateStake(newValue)) { - setStake(newValue); - hideTooltip(); - } - }; - - const handleSelect = (selected: boolean) => { - setIsStakeSelected(selected); - - // Show error tooltip if there's an error - if (error && errorMessage) { - showError(errorMessage); - } - }; - - const handleChange = (e: React.ChangeEvent) => { - // Get cursor position before update - const cursorPosition = e.target.selectionStart; - - // Extract only the number part - let value = e.target.value; - - // If backspace was pressed and we're at the currency part, ignore it - if (value.length < localValue.length && value.endsWith(currency)) { - return; - } - - // Remove currency and any non-numeric characters except decimal point - value = value.replace(new RegExp(`\\s*${currency}$`), "").trim(); - value = value.replace(/[^\d.]/g, ""); - - // Ensure only one decimal point - const parts = value.split("."); - if (parts.length > 2) { - value = parts[0] + "." + parts.slice(1).join(""); - } - - // Remove leading zeros unless it's just "0" - if (value !== "0") { - value = value.replace(/^0+/, ""); - } - - // If it starts with a decimal, add leading zero - if (value.startsWith(".")) { - value = "0" + value; - } - - setLocalValue(value); - - if (value === "") { - setError(true); - const message = "Please enter an amount"; - setErrorMessage(message); - showError(message); - setStake(""); - return; - } - - const numValue = parseFloat(value); - if (!isNaN(numValue)) { - if (validateAndUpdateStake(value)) { - setStake(value); - hideTooltip(); - } - - // Restore cursor position after React updates the input - setTimeout(() => { - if (inputRef.current && cursorPosition !== null) { - inputRef.current.selectionStart = cursorPosition; - inputRef.current.selectionEnd = cursorPosition; + const { stake, setStake, isConfigLoading } = useTradeStore(); + const { currency } = useClientStore(); + const { setBottomSheet } = useBottomSheetStore(); + const { showTooltip, hideTooltip } = useTooltipStore(); + const [isStakeSelected, setIsStakeSelected] = useState(false); + const [error, setError] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const [localValue, setLocalValue] = useState(stake); + const inputRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + setLocalValue(stake); + }, [stake]); + + const showError = (message: string) => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + showTooltip(message, { x: rect.left - 8, y: rect.top + rect.height / 2 }, "error"); } - }, 0); - } - }; - - const handleMobileClick = () => { - setBottomSheet(true, "stake", "400px"); - }; - - return { - stake, - currency, - isStakeSelected, - isConfigLoading, - error, - errorMessage, - localValue, - inputRef, - containerRef, - handleSelect, - handleChange, - handleIncrement, - handleDecrement, - handleMobileClick, - }; + }; + + const validateAndUpdateStake = (value: string) => { + const amount = parseStakeAmount(value || "0"); + const validation = validateStake({ + amount, + minStake: getStakeConfig().min, + maxStake: getStakeConfig().max, + currency, + }); + + setError(validation.error); + setErrorMessage(validation.message); + if (validation.error && validation.message) { + showError(validation.message); + } + return !validation.error; + }; + + const handleIncrement = () => { + const newValue = incrementStake(stake || "0"); + if (validateAndUpdateStake(newValue)) { + setStake(newValue); + hideTooltip(); + } + }; + + const handleDecrement = () => { + const newValue = decrementStake(stake || "0"); + if (validateAndUpdateStake(newValue)) { + setStake(newValue); + hideTooltip(); + } + }; + + const handleSelect = (selected: boolean) => { + setIsStakeSelected(selected); + + // Show error tooltip if there's an error + if (error && errorMessage) { + showError(errorMessage); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + // Get cursor position before update + const cursorPosition = e.target.selectionStart; + + // Extract only the number part + let value = e.target.value; + + // If backspace was pressed and we're at the currency part, ignore it + if (value.length < localValue.length && value.endsWith(currency)) { + return; + } + + // Remove currency and any non-numeric characters except decimal point + value = value.replace(new RegExp(`\\s*${currency}$`), "").trim(); + value = value.replace(/[^\d.]/g, ""); + + // Ensure only one decimal point + const parts = value.split("."); + if (parts.length > 2) { + value = parts[0] + "." + parts.slice(1).join(""); + } + + // Remove leading zeros unless it's just "0" + if (value !== "0") { + value = value.replace(/^0+/, ""); + } + + // If it starts with a decimal, add leading zero + if (value.startsWith(".")) { + value = "0" + value; + } + + setLocalValue(value); + + if (value === "") { + setError(true); + const message = "Please enter an amount"; + setErrorMessage(message); + showError(message); + setStake(""); + return; + } + + const numValue = parseFloat(value); + if (!isNaN(numValue)) { + if (validateAndUpdateStake(value)) { + setStake(value); + hideTooltip(); + } + + // Restore cursor position after React updates the input + setTimeout(() => { + if (inputRef.current && cursorPosition !== null) { + inputRef.current.selectionStart = cursorPosition; + inputRef.current.selectionEnd = cursorPosition; + } + }, 0); + } + }; + + const handleMobileClick = () => { + setBottomSheet(true, "stake", "400px"); + }; + + return { + stake, + currency, + isStakeSelected, + isConfigLoading, + error, + errorMessage, + localValue, + inputRef, + containerRef, + handleSelect, + handleChange, + handleIncrement, + handleDecrement, + handleMobileClick, + }; }; diff --git a/src/components/Stake/utils/validation.ts b/src/components/Stake/utils/validation.ts index 39219bf..1065a68 100644 --- a/src/components/Stake/utils/validation.ts +++ b/src/components/Stake/utils/validation.ts @@ -1,34 +1,34 @@ interface ValidateStakeParams { - amount: number; - minStake: number; - maxStake: number; - currency: string; + amount: number; + minStake: number; + maxStake: number; + currency: string; } interface ValidationResult { - error: boolean; - message?: string; + error: boolean; + message?: string; } export const validateStake = ({ - amount, - minStake, - maxStake, - currency + amount, + minStake, + maxStake, + currency, }: ValidateStakeParams): ValidationResult => { - if (amount < minStake) { - return { - error: true, - message: `Minimum stake is ${minStake} ${currency}` - }; - } + if (amount < minStake) { + return { + error: true, + message: `Minimum stake is ${minStake} ${currency}`, + }; + } - if (amount > maxStake) { - return { - error: true, - message: `Minimum stake of ${minStake} ${currency} and maximum stake of ${maxStake} ${currency}. Current stake is ${amount} ${currency}.` - }; - } + if (amount > maxStake) { + return { + error: true, + message: `Minimum stake of ${minStake} ${currency} and maximum stake of ${maxStake} ${currency}. Current stake is ${amount} ${currency}.`, + }; + } - return { error: false }; + return { error: false }; }; diff --git a/src/components/TradeFields/TradeParam.tsx b/src/components/TradeFields/TradeParam.tsx index f03feac..01fac0c 100644 --- a/src/components/TradeFields/TradeParam.tsx +++ b/src/components/TradeFields/TradeParam.tsx @@ -1,64 +1,56 @@ import React from "react"; import { formatDurationDisplay } from "@/utils/duration"; interface TradeParamProps { - label: string; - value: string; - onClick?: () => void; - className?: string; + label: string; + value: string; + onClick?: () => void; + className?: string; } -const TradeParam: React.FC = ({ - label, - value, - onClick, - className, -}) => { - const formattedValue = - label === "Duration" ? formatDurationDisplay(value) : value; +const TradeParam: React.FC = ({ label, value, onClick, className }) => { + const formattedValue = label === "Duration" ? formatDurationDisplay(value) : value; - const labelClasses = - "text-left font-ibm-plex text-xs leading-[18px] font-normal text-primary"; - const valueClasses = - "text-left font-ibm-plex text-base leading-6 font-normal text-gray-900"; + const labelClasses = "text-left font-ibm-plex text-xs leading-[18px] font-normal text-primary"; + const valueClasses = "text-left font-ibm-plex text-base leading-6 font-normal text-gray-900"; + + if (onClick) { + return ( + + ); + } - if (onClick) { return ( - ); - } - - return ( -
- {label} -
- {typeof formattedValue === "string" ? ( - {formattedValue} - ) : ( - formattedValue - )} -
-
- ); }; export default TradeParam; diff --git a/src/components/ui/README.md b/src/components/ui/README.md index 9a34a83..5ebff08 100644 --- a/src/components/ui/README.md +++ b/src/components/ui/README.md @@ -56,6 +56,68 @@ The component uses TailwindCSS with: ## Components +### Skeleton Component + +A reusable UI component that provides a visual placeholder while content is loading, using a background-only pulse animation to avoid unwanted border effects. + +#### Features +- Background-only pulse animation +- Customizable via className props (TailwindCSS) +- Dark mode support +- Configurable animation colors via CSS variables +- No unwanted border effects + +#### Props +```typescript +interface SkeletonProps extends React.HTMLAttributes {} +``` + +The component accepts all standard HTML div attributes plus: + +| Prop | Type | Description | +|------|------|-------------| +| className | string | TailwindCSS classes for styling | +| style | React.CSSProperties | Additional inline styles | +| [other div props] | any | Any standard HTML div attributes | + +#### Usage +```tsx +import { Skeleton } from '@/components/ui/skeleton'; + +// Basic usage + + +// Circle skeleton (e.g., for avatars) + + +// Card with multiple skeletons +
+ + + + +
+``` + +#### Customization + +The component uses CSS variables for animation colors: + +```tsx +// Custom colors + +``` + +#### Animation + +Uses a custom `animate-pulse-bg` animation that only affects the background color, preventing unwanted border or glow effects. + ### Chip Component A reusable chip/tag component that supports selection states and click interactions. diff --git a/src/components/ui/scroll-select.tsx b/src/components/ui/scroll-select.tsx index 07b1314..d53b1a9 100644 --- a/src/components/ui/scroll-select.tsx +++ b/src/components/ui/scroll-select.tsx @@ -1,21 +1,18 @@ import React, { useEffect, useRef } from "react"; export interface ScrollSelectOption { - value: T; - label: string; + value: T; + label: string; } export interface ScrollSelectProps { - options: ScrollSelectOption[]; - selectedValue: T; - onValueSelect: (value: T) => void; - onValueClick?: (value: T) => void; - itemHeight?: number; - containerHeight?: number; - renderOption?: ( - option: ScrollSelectOption, - isSelected: boolean - ) => React.ReactNode; + options: ScrollSelectOption[]; + selectedValue: T; + onValueSelect: (value: T) => void; + onValueClick?: (value: T) => void; + itemHeight?: number; + containerHeight?: number; + renderOption?: (option: ScrollSelectOption, isSelected: boolean) => React.ReactNode; } const ITEM_HEIGHT = 48; @@ -23,169 +20,160 @@ const CONTAINER_HEIGHT = 268; const SPACER_HEIGHT = 110; export const ScrollSelect = ({ - options, - selectedValue, - onValueSelect, - onValueClick, - itemHeight = ITEM_HEIGHT, - containerHeight = CONTAINER_HEIGHT, - renderOption, + options, + selectedValue, + onValueSelect, + onValueClick, + itemHeight = ITEM_HEIGHT, + containerHeight = CONTAINER_HEIGHT, + renderOption, }: ScrollSelectProps) => { - const containerRef = useRef(null); - const intersectionObserverRef = useRef(); - - const handleClick = (value: T) => { - if (onValueClick) { - onValueClick(value); - } else { - onValueSelect(value); - } - - const clickedItem = containerRef.current?.querySelector( - `[data-value="${value}"]` - ); - if (clickedItem) { - clickedItem.scrollIntoView({ - block: "center", - behavior: "smooth", - }); - } - }; - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - // Store current values in refs to avoid stale closures - const optionsRef = options; - const onValueSelectRef = onValueSelect; - - // First scroll to selected value - const selectedItem = container.querySelector( - `[data-value="${selectedValue}"]` - ); - if (selectedItem) { - selectedItem.scrollIntoView({ block: "center", behavior: "instant" }); - } - - let observerTimeout: NodeJS.Timeout; - - // Add a small delay before setting up the observer to ensure scroll completes - observerTimeout = setTimeout(() => { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const value = entry.target.getAttribute("data-value"); - if (value !== null) { - // Find the option with matching value - const option = optionsRef.find( - (opt) => String(opt.value) === value - ); - if (option) { - onValueSelectRef(option.value); - } - } - } - }); - }, - { - root: container, - rootMargin: "-51% 0px -49% 0px", - threshold: 0, + const containerRef = useRef(null); + const intersectionObserverRef = useRef(); + + const handleClick = (value: T) => { + if (onValueClick) { + onValueClick(value); + } else { + onValueSelect(value); } - ); - const items = container.querySelectorAll(".scroll-select-item"); - items.forEach((item) => observer.observe(item)); + const clickedItem = containerRef.current?.querySelector(`[data-value="${value}"]`); + if (clickedItem) { + clickedItem.scrollIntoView({ + block: "center", + behavior: "smooth", + }); + } + }; - // Store the observer reference - intersectionObserverRef.current = observer; - }, 100); + useEffect(() => { + const container = containerRef.current; + if (!container) return; - // Proper cleanup function - return () => { - clearTimeout(observerTimeout); - if (intersectionObserverRef.current) { - intersectionObserverRef.current.disconnect(); - } - }; - }, []); // Empty dependency array since we handle updates via refs - - return ( -
- {/* Selection zone with gradient background */} -
- - {/* Scrollable content */} -
- {/* Top spacer */} -
- - {options.map((option) => ( -