diff --git a/AGENTS.md b/AGENTS.md index 7b8bbc2e..59d0daac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# StakeKit Widget — Agent Guide +# StakeKit Widget - Agent Guide ## Project Overview - Monorepo managed with `pnpm` workspaces + Turborepo. @@ -26,17 +26,18 @@ - `pnpm lint` — lint/type-check all packages. - `pnpm test` — run all workspace tests. - `pnpm format` — run formatting checks/tasks. -- `pnpm check-hygiene` - check unused deps, unresolved imports, circular deps, etc +- `pnpm check-hygiene` — check unused deps, unresolved imports, circular deps, etc. ### Focused widget commands (recommended for most tasks) - `pnpm --filter @stakekit/widget {command}` ## Agent Working Guidelines (short) - Keep public API compatibility in `src/index.package.ts` and `src/index.bundle.ts`. +- React Compiler is enabled. Do not add `useMemo`, `useCallback`, or `React.memo` only for render-performance optimization; prefer plain values/functions. Use manual memoization only when required for semantic stability, such as an external API dependency or context value identity. - When changing user-facing copy, update both: - `packages/widget/src/translation/English/translations.json` - `packages/widget/src/translation/French/translations.json` -- After changes, confirm nothing is broken with lint command which checks lint/type errors +- After changes, run the lint command to check lint and type errors. ## Useful Context for Debugging - API client is configured in `packages/widget/src/providers/api/api-client-provider.tsx`. @@ -48,11 +49,14 @@ ## Vendored Repositories -This project vendors external repositories under @repos/ +This project vendors external repositories under `@repos/`. - Use vendored repositories as read-only reference material when working with related libraries - Prefer examples and patterns from the vendored source code over generated guesses or web search results -- Do not edit files under @repos/ unless explicitly asked -- Do not import from @repos/ - application code should continue importing from normal package dependencies -- Before writing any Effect code, inspect @repos/effect/LLMS.md +- Do not edit files under `@repos/` unless explicitly asked +- Do not import from `@repos/` - application code should continue importing from normal package dependencies +- `@repos/effect` is a local-only clone of Effect-TS/effect-smol and may be ignored by Git locally +- When searching `@repos/`, use `rg --no-ignore @repos/` so ignored local reference repositories are included without searching unrelated ignored directories +- Before writing any Effect code, inspect `@repos/effect/LLMS.md` - Before writing code that interacts with Effect `HttpClient`, inspect `agent-patterns/effect-http-client.md` +- Before writing code that uses Effect `Stream`, inspect `agent-patterns/effect-stream.md` diff --git a/README.md b/README.md index 4de5d3e8..17dcc4bb 100644 --- a/README.md +++ b/README.md @@ -179,15 +179,13 @@ const App = () => { ); @@ -211,25 +209,6 @@ const App = () => { }; ``` -You can also provide both themes, and widget will respect preference if a user has requested light or dark color themes - -```tsx -import "@stakekit/widget/package/css"; -import { SKApp, darkTheme, lightTheme } from "@stakekit/widget"; - -const App = () => { - return ( - - ); -}; -``` - #### Theme properties: ```ts @@ -640,13 +619,13 @@ Widget component provides `tracking` prop for analytics to track user actions an ```tsx import "@stakekit/widget/package/css"; -import { SKApp, darkTheme, lightTheme } from "@stakekit/widget"; +import { SKApp, darkTheme } from "@stakekit/widget"; const App = () => { return ( { console.log(event, props); diff --git a/agent-patterns/effect-atoms.md b/agent-patterns/effect-atoms.md new file mode 100644 index 00000000..e0b801e4 --- /dev/null +++ b/agent-patterns/effect-atoms.md @@ -0,0 +1,306 @@ +# Effect Atom Patterns + +Use this when writing or changing code that uses Effect's unstable atom +reactivity APIs. The source of truth reviewed for these patterns is the vendored +Effect repo: `@repos/effect/LLMS.md`, +`@repos/effect/packages/effect/src/unstable/reactivity/Atom.ts`, +`AtomRegistry.ts`, `AsyncResult.ts`, `Reactivity.ts`, `Hydration.ts`, +`AtomHttpApi.ts`, `AtomRpc.ts`, and the tests in +`@repos/effect/packages/effect/test/reactivity`. + +## Imports + +Import atom APIs from the unstable reactivity barrel. + +```ts +import { Effect, Layer, Schema, Stream } from "effect" +import { AsyncResult, Atom, AtomRegistry, Hydration } from "effect/unstable/reactivity" +``` + +## Registry Owns State + +An `Atom` is a value description. An `AtomRegistry` owns cached values, +dependency edges, subscriptions, running fibers, stream scopes, and disposal. +Use one registry per isolated lifetime: UI root, request, route boundary, or +test. + +```ts +const count = Atom.make(0) +const doubled = Atom.make((get) => get(count) * 2) + +const registry = AtomRegistry.make() +registry.set(count, 21) +registry.get(doubled) // 42 +``` + +The same atom object can have different values in different registries. After +`registry.dispose()`, later atom access is an error. + +## Keep Atom Identity Stable + +Do not create parameterized atoms inline during reads or renders. Atom identity +is the cache key unless the atom is serializable. Use `Atom.family` for +parameterized atoms so the same input returns the same atom object. + +```ts +const userAtom = Atom.family((id: string) => + UserClient.runtime.atom(UserClient.use((client) => client.getUser({ id }))).pipe( + Atom.withLabel(`user:${id}`) + ) +) +``` + +Use `Atom.withLabel` on important atoms. It only adds diagnostic metadata and +does not change behavior. + +## Choose The Right Constructor + +Use `Atom.make(value)` for writable local state, `Atom.make((get) => value)` for +derived synchronous state, and `Atom.make(effectOrStream)` for read atoms that +produce `AsyncResult`. + +```ts +const search = Atom.make("") + +const trimmedSearch = Atom.make((get) => get(search).trim()) + +const users = Atom.make((get) => + Effect.gen(function*() { + const query = get(trimmedSearch) + return yield* UserApi.use((api) => api.search(query)) + }) +) +``` + +Use `Atom.fn` for command-style effects that run when written to. Use +`Atom.fnSync` for synchronous commands. Before the first write, `Atom.fn` +returns `AsyncResult.initial()` unless `initialValue` is supplied, and +`Atom.fnSync` returns `Option.none()` unless `initialValue` is supplied. + +```ts +const saveUser = Atom.fn<{ readonly id: string; readonly name: string }>()( + Effect.fn("saveUser")(function*(input) { + return yield* UserApi.use((api) => api.save(input)) + }) +) + +registry.set(saveUser, { id: "1", name: "Ada" }) +``` + +Write `Atom.Reset` to clear an `Atom.fn` result back to its initial state and +`Atom.Interrupt` to interrupt the current computation. Set `{ concurrent: true }` +only when multiple writes should run at the same time; the default interrupts +and replaces the previous run. + +## Read Dependencies Deliberately + +Inside an atom read, `get(atom)` records a dependency. When that dependency +changes, the current atom is invalidated. Use `get.once(atom)` when you need the +current value without creating a dependency edge. + +Use `get.result(asyncAtom)` to await an `AsyncResult` atom from another effect +atom. It waits while the result is `Initial`; pass `{ suspendOnWaiting: true }` +when stale values marked `waiting` should also suspend. + +```ts +const enrichedUser = Atom.make((get) => + Effect.gen(function*() { + const user = yield* get.result(userAtom("1"), { suspendOnWaiting: true }) + return { ...user, displayName: user.name.toUpperCase() } + }) +) +``` + +In `Atom.fn` bodies, `get.result` behaves as a one-shot wait instead of a normal +dependency read. If the command should rerun from another atom changing, read +that atom with `get(atom)` as part of the command trigger or use reactivity keys. + +## Handle AsyncResult As State + +`AsyncResult` has three variants: `Initial`, `Success`, and `Failure`. The +`waiting` flag is an overlay, not a fourth variant. A waiting success or failure +can still contain the previous usable value. + +Prefer `AsyncResult.matchWithWaiting`, `AsyncResult.builder`, or explicit +refinements instead of assuming any non-success is a loading state. + +```ts +const view = AsyncResult.matchWithWaiting(result, { + onWaiting: () => "Loading", + onSuccess: ({ value }) => value.name, + onError: (error) => `Error: ${String(error)}`, + onDefect: () => "Unexpected error" +}) +``` + +`AsyncResult.value(result)` and `AsyncResult.getOrElse(result, fallback)` may +return a previous success stored inside a failure. Inspect +`AsyncResult.cause(result)` or `AsyncResult.error(result)` when current failure +versus stale data matters. + +## Manage Lifetime Explicitly + +Unobserved atoms are auto-disposed by default. That means local state can reset, +effects can restart, streams can resubscribe, and finalizers can run after the +last listener or dependent child disappears. + +Use the narrowest lifetime tool that matches the behavior: + +- `Atom.keepAlive` keeps an atom cached even when unobserved. +- `Atom.setIdleTTL(duration)` keeps an unused atom around for a finite idle time. +- `Atom.autoDispose` restores default disposal on a copied atom. +- `registry.mount(atom)` keeps an atom alive until the returned release function + is called. +- `Atom.mount(atom)` keeps an atom alive for the current Effect scope. + +Always release `registry.subscribe` and `registry.mount` callbacks when +integrating with external callback-based code. + +## Batch Related Writes + +Use `Atom.batch` when multiple synchronous writes should invalidate dependents +and notify listeners once after the final state is known. + +```ts +Atom.batch(() => { + registry.set(firstName, "Ada") + registry.set(lastName, "Lovelace") +}) +``` + +Reads inside a batch can still rebuild from the latest written state, but +listeners are notified after the batch commits. + +## Use Runtime Atoms For Services + +Use `Atom.runtime(layer)` or `Atom.context({ memoMap })` when atom effects need +Effect services. The runtime builds the layer with a memo map, provides +`AtomRegistry`, `Scope`, `Scheduler`, and `Reactivity`, and exposes +`runtime.atom`, `runtime.fn`, `runtime.pull`, and `runtime.subscriptionRef`. + +```ts +const UserRuntime = Atom.runtime(UserApi.layer) + +const user = Atom.family((id: string) => + UserRuntime.atom(UserApi.use((api) => api.getUser(id)), { + initialValue: { id, name: "Loading" } + }) +) + +const saveUser = UserRuntime.fn( + Effect.fn("saveUser")(function*(input: User) { + return yield* UserApi.use((api) => api.saveUser(input)) + }), + { reactivityKeys: { users: [] } } +) +``` + +Use registry `initialValues` with `Atom.initialValue(runtime.layer, testLayer)` +to replace runtime services in tests. + +## Invalidate Server State With Reactivity Keys + +Use `Atom.withReactivity(keys)` for reads that should refresh after matching +invalidations. Use `runtime.fn(..., { reactivityKeys })`, +`Reactivity.mutation(effect, keys)`, or `Reactivity.invalidate(keys)` for writes +that should trigger those refreshes after success. + +Keys can be a flat array or a record. Prefer stable primitive keys or stable ids; +non-primitive keys are matched by `Hash.hash`. + +```ts +const user = UserRuntime.atom(UserApi.use((api) => api.getUser("1"))).pipe( + UserRuntime.factory.withReactivity({ users: ["1"] }) +) + +const saveUser = UserRuntime.fn( + (input: User) => UserApi.use((api) => api.saveUser(input)), + { reactivityKeys: { users: ["1"] } } +) +``` + +## Streams, Pulls, And SubscriptionRefs + +An `Atom.make(stream)` stores the latest emitted item in an `AsyncResult`. An +empty stream completes as `NoSuchElementError`. Failures preserve the latest +previous success when possible. + +Use `Atom.pull(stream)` or `runtime.pull(stream)` for paginated or incremental +streams that should advance only when the atom is written to. It accumulates +items by default; pass `{ disableAccumulation: true }` when each pull should +replace the previous batch. + +Use `Atom.subscriptionRef(refOrEffect)` when state already lives in a +`SubscriptionRef`; writes to the atom update the ref. + +## Persistence And Hydration + +Mark atoms that cross registry boundaries with `Atom.serializable({ key, +schema })`. Only serializable atoms are included in `Hydration.dehydrate`, and +`Hydration.hydrate` must run before the matching atoms are first read. + +```ts +const user = userAtom("1").pipe( + Atom.serializable({ + key: "user:1", + schema: AsyncResult.Schema({ + success: User, + error: UserError + }) + }) +) + +const state = Hydration.dehydrate(serverRegistry) +Hydration.hydrate(clientRegistry, state) +``` + +Stable keys matter more than atom identity during hydration. The target atom must +use a compatible schema. `Hydration.dehydrate(..., { encodeInitialAs: "promise" })` +uses a live JavaScript promise for initial async results, so do not serialize +that form across JSON or processes. + +For browser URL state, `Atom.searchParam` requires synchronous schemas with no +Effect context. For storage-backed state, use `Atom.kvs` with an atom runtime +that provides `KeyValueStore`. + +## Remote API Helpers + +Use `AtomHttpApi.Service` or `AtomRpc.Service` when typed HTTP API or RPC +clients should participate in atom caching, invalidation, and hydration. + +- Queries return `Atom>` for non-streaming endpoints. +- Mutations return `AtomResultFn`. +- `reactivityKeys` connect successful mutations to query refreshes. +- `timeToLive` maps to `Atom.setIdleTTL` for finite durations and + `Atom.keepAlive` for infinite durations. +- `serializationKey` is required for serializable queries, and should uniquely + identify the endpoint plus request. +- RPC streaming queries return pull atoms and are not serializable query atoms. + +## Testing Patterns + +Use `it.effect` for Effect-based tests, `AtomRegistry.make()` for an isolated +cache, fake timers or `TestClock` for delayed atoms, and explicit +`registry.mount(atom)` when async work must stay alive during the test. + +```ts +it.effect("refreshes after mutation", () => + Effect.gen(function*() { + const registry = AtomRegistry.make() + const unmount = registry.mount(user) + + registry.set(saveUser, { id: "1", name: "Grace" }) + yield* Effect.yieldNow + + const result = registry.get(user) + assert(AsyncResult.isSuccess(result)) + assert.strictEqual(result.value.name, "Grace") + + unmount() + })) +``` + +Prefer `yield* Effect.yieldNow`, fake timer advancement, or `TestClock` over +real sleeps. Assert `AsyncResult` variants and `waiting` flags directly. For +lifetime behavior, assert node disposal by reading again after yielding, or use +`keepAlive` / `mount` when state should persist. diff --git a/agent-patterns/effect-stream.md b/agent-patterns/effect-stream.md new file mode 100644 index 00000000..a32eaef1 --- /dev/null +++ b/agent-patterns/effect-stream.md @@ -0,0 +1,368 @@ +# Effect Stream Patterns + +Use this when writing project code that models incremental, pull-based data with +Effect `Stream`. The source of truth reviewed for these patterns is the vendored +Effect repo: `@repos/effect/LLMS.md`, +`@repos/effect/ai-docs/src/02_stream/*`, and +`@repos/effect/packages/effect/src/Stream.ts`. + +## Imports + +Prefer the stable `effect` barrel unless nearby code imports individual modules. + +```ts +import { + Cause, + Effect, + Fiber, + Option, + Queue, + Schedule, + Sink, + Stream, + TestClock +} from "effect" +``` + +For Node.js readable streams, use the platform adapter in Node-only code. + +```ts +import { NodeStream } from "@effect/platform-node" +``` + +## Streams Are Lazy Descriptions + +A `Stream.Stream` describes a sequence that can emit zero or more `A` +values, fail with `E`, and require services from `R`. It does not run until it +is consumed with `Stream.run*`, `Stream.run`, `Stream.toQueue`, +`Stream.toReadableStream*`, or a similar destructor. + +```ts +const stream = Stream.fromEffect(loadConfig) + +const program = stream.pipe( + Stream.map((config) => config.region), + Stream.runCollect +) +``` + +Reusing the same stream value reruns the description for each consumer. If +several consumers must observe one running producer, use `Stream.share`, +`Stream.broadcast`, `Stream.broadcastN`, a `Queue`, or a `PubSub`. + +## Choose The Smallest Constructor + +Use the constructor that matches the source shape. + +- `Stream.empty`, `Stream.succeed`, `Stream.make`, `Stream.fromIterable` for + in-memory values. +- `Stream.fromEffect` for one effectful value. +- `Stream.fromEffectSchedule` for polling an effect over a schedule. +- `Stream.paginate` for cursor or page APIs. +- `Stream.fromAsyncIterable` for existing async iterables. +- `Stream.fromEventListener` for DOM-style event targets. +- `Stream.callback` for callback APIs that need explicit queue control. +- `Stream.fromQueue` and `Stream.fromPubSub` for Effect concurrency primitives. +- `Stream.fromReadableStream` or `NodeStream.fromReadable` for web or Node + readable streams. + +```ts +const jobs = Stream.paginate(0, (page) => + JobApi.use((api) => api.listJobs({ page })).pipe( + Effect.map(({ items, nextPage }) => [ + items, + nextPage === null ? Option.none() : Option.some(nextPage) + ] as const) + ) +) +``` + +Prefer `Stream.paginate` over building a mutable loop with `Stream.callback` for +normal paginated APIs. Prefer `Stream.fromEffectSchedule` over hand-written +sleep loops for polling. + +## Consume With Intent + +Every stream pipeline should end in a clear consumer. + +```ts +const writeEvents = events.pipe( + Stream.runForEach((event) => EventStore.use((store) => store.write(event))) +) +``` + +Use `Stream.runCollect` only for finite streams with bounded output. For large +or infinite streams, prefer `Stream.runForEach`, `Stream.runFold`, +`Stream.runFoldEffect`, `Stream.runDrain`, or `Stream.run(Sink...)`. + +```ts +const firstTen = source.pipe( + Stream.take(10), + Stream.runCollect +) + +const total = source.pipe( + Stream.map((event) => event.value), + Stream.run(Sink.sum) +) +``` + +`Stream.runHead` and `Stream.runLast` return `Option`. `runLast` waits for the +stream to complete, so do not use it on open-ended streams. + +## Transform Per Element Or Per Chunk Deliberately + +Use element operators for ordinary domain logic. + +```ts +const enriched = orders.pipe( + Stream.filter((order) => order.status === "paid"), + Stream.mapEffect(enrichOrder, { concurrency: 4 }) +) +``` + +Use chunk-aware operators only when chunk boundaries matter or performance is +worth the extra complexity: `Stream.mapArray`, `Stream.mapArrayEffect`, +`Stream.runForEachArray`, `Stream.grouped`, and `Stream.groupedWithin`. + +```ts +const batched = events.pipe( + Stream.groupedWithin(100, "1 second"), + Stream.mapEffect((batch) => EventStore.use((store) => store.writeBatch(batch))) +) +``` + +Do not insert `runCollect` in the middle of a pipeline just to batch values. +Batch with stream operators so backpressure and interruption still work. + +## Bound Concurrency And Buffers + +`Stream.mapEffect`, `Stream.flatMap`, `Stream.mergeAll`, and related operators +can run work concurrently. Choose a concrete concurrency limit for I/O and only +use `"unbounded"` when the upstream is already tightly bounded. + +```ts +const results = ids.pipe( + Stream.mapEffect((id) => RemoteApi.use((api) => api.fetch(id)), { + concurrency: 8 + }) +) +``` + +Set `unordered: true` only when output order does not matter. Concurrent +`Stream.mergeAll` emits values as they arrive. + +```ts +const merged = Stream.mergeAll(streams, { + concurrency: 4, + bufferSize: 16 +}) +``` + +When creating queues, pubsubs, callbacks, broadcasts, or shared streams, avoid +unbounded capacity by default. Pick a bounded `capacity` and a strategy: + +- `suspend` applies backpressure. +- `sliding` keeps newer values and drops older buffered values. +- `dropping` keeps older buffered values and drops newer values. + +## Manage Scope And Cleanup + +Streams run resources for the duration of consumption. Use `Stream.scoped` when +the stream requires `Scope`, and use `Stream.ensuring` or +`Effect.acquireRelease` inside constructors to register finalizers. + +```ts +const resourceStream = Stream.scoped( + Stream.fromEffect( + Effect.acquireRelease( + Connection.open, + (connection) => connection.close() + ) + ) +) +``` + +`Stream.broadcast`, `Stream.broadcastN`, `Stream.share`, `Stream.toQueue`, and +`Stream.toPubSub` return scoped effects. Acquire them inside `Effect.scoped` or +inside a layer so the producer and subscribers are finalized. + +```ts +const program = Effect.scoped( + Effect.gen(function*() { + const shared = yield* updates.pipe( + Stream.share({ capacity: 16, replay: 1 }) + ) + + yield* shared.pipe(Stream.take(1), Stream.runCollect) + }) +) +``` + +When bridging to web or async protocols, make sure cancellation closes the +underlying handle. `Stream.fromReadableStream` cancels the reader by default. +`NodeStream.fromReadable` can close Node streams when done. + +## Bridge Callback APIs Safely + +Use `Stream.fromEventListener` for event targets when it fits. It registers and +removes the listener for the stream lifetime. + +```ts +const clicks = Stream.fromEventListener(button, "click", { + passive: true, + bufferSize: 16 +}) +``` + +Use `Stream.callback` for custom callback APIs. Register cleanup with +`Effect.acquireRelease`, and signal completion with the queue API instead of +leaving consumers waiting forever. + +```ts +const messages = Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const unsubscribeMessage = socket.onMessage((message) => { + Queue.offerUnsafe(queue, message) + }) + + const unsubscribeClose = socket.onClose(() => { + Queue.endUnsafe(queue) + }) + + return { unsubscribeClose, unsubscribeMessage } + }), + ({ unsubscribeClose, unsubscribeMessage }) => + Effect.sync(() => { + unsubscribeMessage() + unsubscribeClose() + }) + ), + { bufferSize: 64, strategy: "sliding" } +) +``` + +Prefer effectful `Queue.offer` when producer code is already inside Effect and +can honor backpressure. Use `Queue.offerUnsafe` only from external synchronous +callbacks where an Effect cannot be yielded. + +## Use Queues And PubSubs For Boundaries + +Use `Queue` when one producer coordinates with one or more competing consumers +that pull work. `Stream.toQueue` creates a scoped dequeue and signals completion +with `Cause.Done`; stream failures fail the queue. + +```ts +const program = Effect.scoped( + Effect.gen(function*() { + const queue = yield* source.pipe(Stream.toQueue({ capacity: 32 })) + const next = yield* Queue.take(queue) + return next + }) +) +``` + +Use `PubSub` when each subscriber should receive published events. Use +`Stream.fromPubSub` or `Stream.broadcast` instead of manually copying events +into several queues. + +## Provide Services At The Stream Boundary + +Service requirements flow through stream types the same way they flow through +`Effect`. Provide a layer to the stream or provide a larger program that runs +the stream. + +```ts +const stream = Stream.fromEffect( + UserApi.use((api) => api.listUsers()) +).pipe( + Stream.flatMap(Stream.fromIterable), + Stream.provide(UserApi.layer) +) +``` + +When converting to web APIs outside Effect, use the service-aware variants: +`Stream.toReadableStreamEffect`, `Stream.toReadableStreamWith`, +`Stream.toAsyncIterableEffect`, or `Stream.toAsyncIterableWith`. + +## Handle Errors In The Pipeline + +Use stream error combinators when recovery should continue as a stream: +`Stream.catchTag`, `Stream.catchTags`, `Stream.catchIf`, `Stream.catchCause`, +`Stream.mapError`, and `Stream.retry`. + +```ts +const recovered = source.pipe( + Stream.catchTag("RateLimitError", () => + Stream.fromEffectSchedule( + Effect.succeed({ type: "retrying" as const }), + Schedule.spaced("1 second") + ).pipe(Stream.take(1)) + ) +) +``` + +Use `Effect.catchTag` after a `Stream.run*` call when the whole consumed stream +should fail or recover as one effect. Use `Stream.result` when downstream code +needs successes and the first failure as values; the stream still ends after +that failure. + +## Decode And Encode Streaming Data + +For byte streams, decode text before string operations and split lines with +`Stream.splitLines` so delimiters spanning chunks are handled correctly. + +```ts +const lines = responseBytes.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach(processLine) +) +``` + +For NDJSON or Msgpack, use the encoding channels and schema-backed variants +instead of hand-parsing inside `Stream.map`. + +```ts +import { Ndjson } from "effect/unstable/encoding" + +const events = bytes.pipe( + Stream.pipeThroughChannel(Ndjson.decodeSchema(Event)()), + Stream.mapError((cause) => new EventDecodeError({ cause })) +) +``` + +Use `ignoreEmptyLines: true` for NDJSON inputs that may contain blank lines. + +## Testing Patterns + +Use `it.effect` and consume the stream in the test. Bound infinite streams with +`Stream.take`, and use `TestClock` for schedules, debounce, throttle, retries, +or `groupedWithin`. + +```ts +import { strictEqual } from "node:assert" +import { Effect, Fiber, Schedule, Stream, TestClock } from "effect" + +it.effect("polls three times", () => + Effect.gen(function*() { + const fiber = yield* Stream.fromEffectSchedule( + Effect.succeed("tick"), + Schedule.spaced("1 second") + ).pipe( + Stream.take(3), + Stream.runCollect, + Effect.fork + ) + + yield* TestClock.adjust("2 seconds") + + const values = yield* Fiber.join(fiber) + strictEqual(values.length, 3) + })) +``` + +For queue and callback streams, assert finalization as well as emitted values. +Fork consumers before publishing when the source is live, then interrupt or close +the scope to verify cleanup. diff --git a/packages/widget/package.json b/packages/widget/package.json index 9a1417e2..09b600bb 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -156,7 +156,8 @@ "vitest-browser-react": "catalog:", "wagmi": "catalog:", "xstate": "catalog:", - "yaml": "catalog:" + "yaml": "catalog:", + "recharts": "catalog:" }, "msw": { "workerDirectory": [ diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx index 655fa278..8bc4be7c 100644 --- a/packages/widget/src/App.tsx +++ b/packages/widget/src/App.tsx @@ -9,7 +9,6 @@ import { createMemoryRouter, RouterProvider } from "react-router"; import { preloadImages } from "./assets/images"; import { Box } from "./components/atoms/box"; import { Dashboard } from "./Dashboard"; -import { useToggleTheme } from "./hooks/use-toggle-theme"; import { Providers } from "./providers"; import { SettingsContextProvider, useSettings } from "./providers/settings"; import type { SettingsProps, VariantProps } from "./providers/settings/types"; @@ -20,7 +19,6 @@ import { Widget } from "./Widget"; preloadImages(); const App = () => { - useToggleTheme(); useLoadErrorTranslations(); const { dashboardVariant } = useSettings(); diff --git a/packages/widget/src/Dashboard.tsx b/packages/widget/src/Dashboard.tsx index a43f13f0..91fb2ca0 100644 --- a/packages/widget/src/Dashboard.tsx +++ b/packages/widget/src/Dashboard.tsx @@ -5,6 +5,8 @@ import { ConnectedCheck } from "./navigation/cheks/connected-check"; import { PendingCompletePage } from "./pages/complete/pages/pending-complete.page"; import { StakeCompletePage } from "./pages/complete/pages/stake-complete.page"; import { UnstakeCompletePage } from "./pages/complete/pages/unstake-complete.page"; +import { EarnPageContextProvider } from "./pages/details/earn-page/state/earn-page-context"; +import { EarnPageStateUsageBoundaryProvider } from "./pages/details/earn-page/state/earn-page-state-context"; import { StakeReviewPage } from "./pages/review"; import { PendingReviewPage } from "./pages/review/pages/pending-review.page"; import { UnstakeReviewPage } from "./pages/review/pages/unstake-review.page"; @@ -16,67 +18,99 @@ import { ActivityTabPage } from "./pages-dashboard/activity"; import { ActivityDetailsPage } from "./pages-dashboard/activity/activity-details.page"; import { DashboardWrapper } from "./pages-dashboard/common/components/wrapper"; import { OverviewPage } from "./pages-dashboard/overview"; -import { EarnPage } from "./pages-dashboard/overview/earn-page"; +import { EarnPageContent } from "./pages-dashboard/overview/earn-page"; +import { ManagePage } from "./pages-dashboard/overview/manage.page"; import { PositionDetailsPage } from "./pages-dashboard/position-details"; import { PositionDetailsActions } from "./pages-dashboard/position-details/components/position-details-actions"; +import { PositionDetailsStakeActions } from "./pages-dashboard/position-details/components/position-details-stake-actions"; import { DashboardProvider } from "./pages-dashboard/providers/dashboard-context"; +import { useSKLocation } from "./providers/location"; + +const positionDetailsStakeFooterPath = + /^\/positions\/[^/]+\/[^/]+(?:\/stake)?$/; + +export const shouldRegisterDashboardEarnFooterButton = (pathname: string) => + pathname === "/" || positionDetailsStakeFooterPath.test(pathname); export const Dashboard = () => { + const { current } = useSKLocation(); + const registerEarnFooterButton = shouldRegisterDashboardEarnFooterButton( + current.pathname + ); + return ( - - }> - {/* Overview Tab */} - }> - } /> + + + + }> + {/* Earn Tab */} + }> + } /> - }> - } /> - } /> - } /> - - + }> + } /> + } /> + } /> + + - {/* Position Details */} - } - > - } /> + {/* Manage Tab */} + } /> - } - /> + {/* Position Details */} + } + > + } /> - {/* Unstaking */} - - } /> - } /> - } /> - + {/* Staking */} + + } /> + } /> + } /> + } /> + - {/* Pending Actions */} - - } /> - } /> - } /> - - + } + /> + + {/* Unstaking */} + + } /> + } /> + } /> + } /> + - {/* Rewards Tab */} - {/* } /> */} + {/* Pending Actions */} + + } /> + } /> + } /> + + - {/* Activity Tab */} - }> - } /> - } - /> - - - + {/* Rewards Tab */} + {/* } /> */} + + {/* Activity Tab */} + }> + } /> + } + /> + + + + + diff --git a/packages/widget/src/components/atoms/button/styles.css.ts b/packages/widget/src/components/atoms/button/styles.css.ts index b56d644d..e5a4e3b3 100644 --- a/packages/widget/src/components/atoms/button/styles.css.ts +++ b/packages/widget/src/components/atoms/button/styles.css.ts @@ -4,6 +4,9 @@ import { recipe } from "@vanilla-extract/recipes"; import { atoms } from "../../../styles/theme/atoms.css"; import { vars } from "../../../styles/theme/contract.css"; +const lighterBackground = (background: string) => + `color-mix(in oklab, ${background} 88%, white)`; + export const pressAnimation = style({ transition: "transform 0.1s ease-in-out", ":active": { @@ -17,7 +20,9 @@ export const selectTokenButton = recipe({ default: [atoms({ background: "background" })], utila: [atoms({ background: "background" })], finery: [ - atoms({ background: "__internal__finery__grey__two__" }), + { + background: vars.color.tokenSelectBackground, + }, { boxShadow: "0px 15px 30px 0px #0000000D", ":hover": { @@ -56,20 +61,14 @@ export const buttonStyle = recipe({ atoms({ background: "primaryButtonBackground", color: "primaryButtonColor", - borderColor: "primaryButtonOutline", borderRadius: "primaryButton", fontWeight: "primaryButton", }), { + borderColor: vars.color.primaryButtonBackground, ":hover": { - background: vars.color.primaryButtonHoverBackground, - color: vars.color.primaryButtonHoverColor, - borderColor: vars.color.primaryButtonHoverOutline, - }, - ":active": { - background: vars.color.primaryButtonActiveBackground, - color: vars.color.primaryButtonActiveColor, - borderColor: vars.color.primaryButtonActiveOutline, + background: lighterBackground(vars.color.primaryButtonBackground), + borderColor: lighterBackground(vars.color.primaryButtonBackground), }, }, ], @@ -77,20 +76,13 @@ export const buttonStyle = recipe({ atoms({ background: "secondaryButtonBackground", color: "secondaryButtonColor", - borderColor: "secondaryButtonOutline", borderRadius: "secondaryButton", fontWeight: "secondaryButton", }), { + borderColor: vars.color.secondaryButtonColor, ":hover": { - background: vars.color.secondaryButtonHoverBackground, - color: vars.color.secondaryButtonHoverColor, - borderColor: vars.color.secondaryButtonHoverOutline, - }, - ":active": { - background: vars.color.secondaryButtonActiveBackground, - color: vars.color.secondaryButtonActiveColor, - borderColor: vars.color.secondaryButtonActiveOutline, + background: lighterBackground(vars.color.secondaryButtonBackground), }, }, ], @@ -99,9 +91,9 @@ export const buttonStyle = recipe({ atoms({ background: "disabledButtonBackground", color: "disabledButtonColor", - borderColor: "disabledButtonOutline", }), { + borderColor: vars.color.disabledButtonBackground, ":active": { transform: "none", }, @@ -111,20 +103,14 @@ export const buttonStyle = recipe({ atoms({ background: "smallButtonBackground", color: "smallButtonColor", - borderColor: "smallButtonOutline", borderRadius: "secondaryButton", fontWeight: "secondaryButton", }), { + borderColor: vars.color.smallButtonBackground, ":hover": { - background: vars.color.smallButtonHoverBackground, - color: vars.color.secondaryButtonHoverColor, - borderColor: vars.color.smallButtonHoverOutline, - }, - ":active": { - background: vars.color.smallButtonActiveBackground, - color: vars.color.secondaryButtonActiveColor, - borderColor: vars.color.smallButtonActiveOutline, + background: lighterBackground(vars.color.smallButtonBackground), + borderColor: lighterBackground(vars.color.smallButtonBackground), }, }, ], @@ -132,20 +118,18 @@ export const buttonStyle = recipe({ atoms({ background: "smallLightButtonBackground", color: "smallLightButtonColor", - borderColor: "smallLightButtonOutline", borderRadius: "secondaryButton", fontWeight: "secondaryButton", }), { + borderColor: vars.color.smallLightButtonBackground, ":hover": { - background: vars.color.smallLightButtonHoverBackground, - color: vars.color.smallLightButtonHoverColor, - borderColor: vars.color.smallLightButtonHoverOutline, - }, - ":active": { - background: vars.color.smallLightButtonActiveBackground, - color: vars.color.smallLightButtonActiveColor, - borderColor: vars.color.smallLightButtonActiveOutline, + background: lighterBackground( + vars.color.smallLightButtonBackground + ), + borderColor: lighterBackground( + vars.color.smallLightButtonBackground + ), }, }, ], diff --git a/packages/widget/src/components/atoms/divider/styles.css.ts b/packages/widget/src/components/atoms/divider/styles.css.ts index 53ed5cef..8750164f 100644 --- a/packages/widget/src/components/atoms/divider/styles.css.ts +++ b/packages/widget/src/components/atoms/divider/styles.css.ts @@ -1,5 +1,6 @@ import { recipe } from "@vanilla-extract/recipes"; import { atoms } from "../../../styles/theme/atoms.css"; +import { utilaPalette } from "../../../styles/theme/variant-overrides/palettes"; export const divider = recipe({ base: atoms({ @@ -9,7 +10,7 @@ export const divider = recipe({ variants: { variant: { default: atoms({ background: "backgroundMuted" }), - utila: atoms({ background: "__internal__utila__tab__page__divider__" }), + utila: { background: utilaPalette.tabPageDivider }, }, }, defaultVariants: { diff --git a/packages/widget/src/components/atoms/icons/arrow.tsx b/packages/widget/src/components/atoms/icons/arrow.tsx new file mode 100644 index 00000000..e6f8b19a --- /dev/null +++ b/packages/widget/src/components/atoms/icons/arrow.tsx @@ -0,0 +1,40 @@ +import { vars } from "../../../styles/theme/contract.css"; + +type ArrowDirection = "down" | "up" | "left" | "right"; + +const rotation: Record = { + down: 0, + up: 180, + left: 90, + right: -90, +}; + +export const Arrow = ({ + size = 16, + color, + direction, +}: { + size?: number; + color?: string; + direction: ArrowDirection; +}) => ( + + + +); diff --git a/packages/widget/src/components/atoms/icons/balance.tsx b/packages/widget/src/components/atoms/icons/balance.tsx deleted file mode 100644 index 67614113..00000000 --- a/packages/widget/src/components/atoms/icons/balance.tsx +++ /dev/null @@ -1,17 +0,0 @@ -export const Balance = () => ( - - - -); diff --git a/packages/widget/src/components/atoms/icons/utila/fail.tsx b/packages/widget/src/components/atoms/icons/utila/fail.tsx deleted file mode 100644 index 83e7c29f..00000000 --- a/packages/widget/src/components/atoms/icons/utila/fail.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export const UtilaFailIcon = () => ( - - - -); diff --git a/packages/widget/src/components/atoms/icons/utila/success.tsx b/packages/widget/src/components/atoms/icons/utila/success.tsx deleted file mode 100644 index caeb6b28..00000000 --- a/packages/widget/src/components/atoms/icons/utila/success.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export const UtilaSuccessIcon = () => ( - - - -); diff --git a/packages/widget/src/components/atoms/list/styles.css.ts b/packages/widget/src/components/atoms/list/styles.css.ts index 48d79d8d..9f4a8644 100644 --- a/packages/widget/src/components/atoms/list/styles.css.ts +++ b/packages/widget/src/components/atoms/list/styles.css.ts @@ -9,14 +9,20 @@ export const itemContainer = recipe({ display: "flex", justifyContent: "space-between", alignItems: "center", - px: { tablet: "4", mobile: "3" }, + px: "3", py: "3", borderRadius: "xl", flex: 1, - background: "tokenSelectBackground", }), ], variants: { + appearance: { + card: atoms({ background: "tokenSelectBackground" }), + plain: [ + atoms({ background: "transparent" }), + { border: "1px solid transparent", boxSizing: "border-box" }, + ], + }, hover: { disabled: {}, enabled: { @@ -32,6 +38,7 @@ export const itemContainer = recipe({ }, }, defaultVariants: { + appearance: "card", hover: "enabled", type: "enabled", }, diff --git a/packages/widget/src/components/atoms/max-button/styles.css.ts b/packages/widget/src/components/atoms/max-button/styles.css.ts index 6810385c..1c044c3f 100644 --- a/packages/widget/src/components/atoms/max-button/styles.css.ts +++ b/packages/widget/src/components/atoms/max-button/styles.css.ts @@ -1,5 +1,6 @@ import { recipe } from "@vanilla-extract/recipes"; import { atoms } from "../../../styles/theme/atoms.css"; +import { utilaPalette } from "../../../styles/theme/variant-overrides/palettes"; export const container = recipe({ base: [ @@ -14,7 +15,7 @@ export const container = recipe({ default: atoms({ background: "background", borderRadius: "xl" }), utila: [ { borderRadius: "4px" }, - atoms({ background: "__internal__utila__max__button__background__" }), + { background: utilaPalette.maxButtonBackground }, ], }, }, @@ -27,10 +28,14 @@ export const text = recipe({ variants: { variant: { default: atoms({ color: "text", fontWeight: "semibold" }), - utila: atoms({ - color: "__internal__utila__max__button__text__", - fontWeight: "normal", - }), + utila: [ + { + color: utilaPalette.maxButtonText, + }, + atoms({ + fontWeight: "normal", + }), + ], }, }, defaultVariants: { diff --git a/packages/widget/src/components/atoms/select-modal/index.tsx b/packages/widget/src/components/atoms/select-modal/index.tsx index ff681e9f..8487f9db 100644 --- a/packages/widget/src/components/atoms/select-modal/index.tsx +++ b/packages/widget/src/components/atoms/select-modal/index.tsx @@ -1,10 +1,12 @@ import { Content, Overlay, Portal, Root, Title } from "@radix-ui/react-dialog"; import { Root as VisuallyHiddenRoot } from "@radix-ui/react-visually-hidden"; +import clsx from "clsx"; import type { ChangeEvent, PropsWithChildren, ReactNode } from "react"; import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { useSavedRef } from "../../../hooks/use-saved-ref"; -import { SettingsContext } from "../../../providers/settings"; +import { SettingsContext, useSettings } from "../../../providers/settings"; import { id } from "../../../styles/theme/ids"; +import { combineRecipeWithVariant } from "../../../utils/styles"; import { Box } from "../box"; import { SearchIcon } from "../icons/search"; import { XIcon } from "../icons/x-icon"; @@ -17,6 +19,7 @@ import { content, noOutline, overlay, + selectedListItem, selectModalItemContainer, } from "./styles.css"; @@ -230,17 +233,35 @@ export const SelectModalItem = ({ onItemClick, testId, variant, + className, + selected, }: PropsWithChildren<{ onItemClick?: (args: { closeModal: () => void }) => void; testId?: string; variant?: ItemContainerVariants; + className?: string; + selected?: boolean; }>) => { const { setOpen } = useSelectModalContext(); + const { variant: themeVariant } = useSettings(); const onClick = () => onItemClick?.({ closeModal: () => setOpen(false) }); return ( - + {children} ); diff --git a/packages/widget/src/components/atoms/select-modal/styles.css.ts b/packages/widget/src/components/atoms/select-modal/styles.css.ts index 6209466f..4b648868 100644 --- a/packages/widget/src/components/atoms/select-modal/styles.css.ts +++ b/packages/widget/src/components/atoms/select-modal/styles.css.ts @@ -1,6 +1,8 @@ import { keyframes, style } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; import { atoms } from "../../../styles/theme/atoms.css"; import { vars } from "../../../styles/theme/contract.css"; +import { utilaPalette } from "../../../styles/theme/variant-overrides/palettes"; import { breakpoints, minMediaQuery } from "../../../styles/tokens/breakpoints"; const slideUp = keyframes({ @@ -39,7 +41,7 @@ export const content = style([ { "@media": { [minMediaQuery("tablet")]: { - width: "380px", + width: "420px", }, }, animation: `${slideUp} 350ms cubic-bezier(.15,1.15,0.6,1.00), ${fadeIn} 150ms ease`, @@ -78,3 +80,41 @@ export const noOutline = style({ outline: "none" }); export const selectModalItemContainer = style({ padding: "2.5px 0", }); + +export const selectModalGroupLabel = style({ + textTransform: "uppercase", + letterSpacing: "0.06em", +}); + +export const selectedListItem = recipe({ + base: [ + atoms({ + background: "tokenSelectHoverBackground", + }), + { + border: `1px solid ${vars.color.accent}`, + selectors: { + "&:hover": { + background: vars.color.tokenSelectHoverBackground, + }, + }, + }, + ], + variants: { + variant: { + default: {}, + utila: { + background: `${utilaPalette.primaryBlue}14`, + border: `1px solid ${utilaPalette.primaryBlue}`, + selectors: { + "&:hover": { + background: `${utilaPalette.primaryBlue}14`, + }, + }, + }, + }, + }, + defaultVariants: { + variant: "default", + }, +}); diff --git a/packages/widget/src/components/molecules/account-modal/styles.css.ts b/packages/widget/src/components/molecules/account-modal/styles.css.ts index 62a65c79..229fe181 100644 --- a/packages/widget/src/components/molecules/account-modal/styles.css.ts +++ b/packages/widget/src/components/molecules/account-modal/styles.css.ts @@ -18,11 +18,11 @@ export const container = recipe({ variant: { default: {}, finery: { - background: vars.color.__internal__finery__summary__item__background__, + background: vars.color.summaryItemBackground, boxShadow: "0px 15px 30px 0px #0000000D", }, porto: { - background: vars.color.__internal__porto__grey__two__, + background: vars.color.summaryItemBackground, }, utila: {}, }, diff --git a/packages/widget/src/components/molecules/chain-modal/styles.css.ts b/packages/widget/src/components/molecules/chain-modal/styles.css.ts index cb91fb59..98bae9ac 100644 --- a/packages/widget/src/components/molecules/chain-modal/styles.css.ts +++ b/packages/widget/src/components/molecules/chain-modal/styles.css.ts @@ -18,11 +18,11 @@ export const container = recipe({ variant: { default: {}, finery: { - background: vars.color.__internal__finery__summary__item__background__, + background: vars.color.summaryItemBackground, boxShadow: "0px 15px 30px 0px #0000000D", }, porto: { - background: vars.color.__internal__porto__grey__two__, + background: vars.color.summaryItemBackground, }, }, }, diff --git a/packages/widget/src/components/molecules/kyc-gate-card/index.tsx b/packages/widget/src/components/molecules/kyc-gate-card/index.tsx new file mode 100644 index 00000000..b51a7168 --- /dev/null +++ b/packages/widget/src/components/molecules/kyc-gate-card/index.tsx @@ -0,0 +1,202 @@ +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { KycGate } from "../../../domain/types/kyc"; +import { vars } from "../../../styles/theme/contract.css"; +import { MaybeWindow } from "../../../utils/maybe-window"; +import { Box } from "../../atoms/box"; +import { Button } from "../../atoms/button"; +import { Arrow } from "../../atoms/icons/arrow"; +import { WarningIcon } from "../../atoms/icons/warning"; +import { Spinner } from "../../atoms/spinner"; +import { Text } from "../../atoms/typography/text"; +import { + cardStyle, + iconContainerStyle, + spinnerContainerStyle, +} from "./styles.css"; +import { KycVerificationModal } from "./verification-modal"; + +type KycGateCardProps = { + readonly gate: KycGate; + readonly isChecking?: boolean; + readonly onCheckStatus?: () => void; + readonly providerName: string | null; +}; + +type CardState = Exclude | "checking"; + +const translationKeys = { + checking: { + title: "details.kyc_gate.checking.title", + body: "details.kyc_gate.checking.body", + bodyFallback: "details.kyc_gate.checking.body_fallback", + }, + pending: { + title: "details.kyc_gate.pending.title", + body: "details.kyc_gate.pending.body", + bodyFallback: "details.kyc_gate.pending.body_fallback", + }, + rejected: { + title: "details.kyc_gate.rejected.title", + body: "details.kyc_gate.rejected.body", + bodyFallback: "details.kyc_gate.rejected.body_fallback", + cta: "details.kyc_gate.rejected.cta", + }, + start_kyc: { + title: "details.kyc_gate.start_kyc.title", + body: "details.kyc_gate.start_kyc.body", + bodyFallback: "details.kyc_gate.start_kyc.body_fallback", + cta: "details.kyc_gate.start_kyc.cta", + }, + unknown: { + title: "details.kyc_gate.unknown.title", + body: "details.kyc_gate.unknown.body", + bodyFallback: "details.kyc_gate.unknown.body_fallback", + }, +} as const satisfies Record< + CardState, + { + readonly title: string; + readonly body: string; + readonly bodyFallback: string; + readonly cta?: string; + } +>; + +const getCardState = ({ + gate, + isChecking, +}: Pick): CardState | null => { + if (isChecking) return "checking"; + if (gate.state === "pass") return null; + + return gate.state; +}; + +const getKycUrl = (gate: KycGate) => + gate.state === "pass" ? undefined : gate.kycUrl; + +export const KycGateCard = ({ + gate, + isChecking = false, + onCheckStatus, + providerName, +}: KycGateCardProps) => { + const { t } = useTranslation(); + const [verificationModalOpen, setVerificationModalOpen] = useState(false); + const refreshOnVerificationClose = useRef(false); + const cardState = getCardState({ gate, isChecking }); + + if (!cardState) return null; + + const kycUrl = getKycUrl(gate); + const copy = translationKeys[cardState]; + const values = { providerName: providerName ?? "" }; + const hasProviderName = !!providerName; + const bodyKey = hasProviderName ? copy.body : copy.bodyFallback; + const ctaKey = + cardState === "start_kyc" || cardState === "rejected" + ? translationKeys[cardState].cta + : null; + const showVerifyButton = !!ctaKey && !!kycUrl; + const showCheckStatusButton = + cardState === "pending" || cardState === "unknown"; + const showEmbeddedVerification = + showVerifyButton && gate.state !== "pass" && gate.iframeAllowed === true; + + const onVerifyClick = () => { + if (!kycUrl) return; + + if (showEmbeddedVerification) { + refreshOnVerificationClose.current = true; + setVerificationModalOpen(true); + return; + } + + MaybeWindow.ifJust((w) => { + w.open(kycUrl, "_blank", "noopener,noreferrer"); + }); + }; + + const onVerificationModalOpenChange = (isOpen: boolean) => { + if (isOpen) { + refreshOnVerificationClose.current = true; + setVerificationModalOpen(true); + return; + } + + if (refreshOnVerificationClose.current) { + refreshOnVerificationClose.current = false; + onCheckStatus?.(); + } + + setVerificationModalOpen(false); + }; + + return ( + <> + + + + + {t(copy.title)} + + {cardState === "checking" ? ( + + + + ) : ( + + + + )} + + + {t(bodyKey, values)} + + + {showVerifyButton && ( + + )} + + {showCheckStatusButton && ( + + )} + + + + + + {showEmbeddedVerification && ( + + )} + + ); +}; diff --git a/packages/widget/src/components/molecules/kyc-gate-card/styles.css.ts b/packages/widget/src/components/molecules/kyc-gate-card/styles.css.ts new file mode 100644 index 00000000..c727ccd6 --- /dev/null +++ b/packages/widget/src/components/molecules/kyc-gate-card/styles.css.ts @@ -0,0 +1,170 @@ +import { keyframes, style } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; +import { atoms } from "../../../styles/theme/atoms.css"; +import { vars } from "../../../styles/theme/contract.css"; + +const modalFadeIn = keyframes({ + "0%": { opacity: 0 }, + "100%": { opacity: 1 }, +}); + +const modalScaleIn = keyframes({ + "0%": { opacity: 0, transform: "translateY(12px) scale(0.98)" }, + "100%": { opacity: 1, transform: "translateY(0) scale(1)" }, +}); + +const stateStyles = { + checking: { + background: vars.color.backgroundMuted, + borderColor: "transparent", + }, + start_kyc: { + background: "color-mix(in oklab, #5147f3 8%, transparent)", + borderColor: "#5147f3", + }, + pending: { + background: "color-mix(in oklab, #ffc21b 16%, transparent)", + borderColor: "#ffc21b", + }, + rejected: { + background: "color-mix(in oklab, #ff3b1f 10%, transparent)", + borderColor: "#ff3b1f", + }, + unknown: { + background: vars.color.backgroundMuted, + borderColor: vars.color.textMuted, + }, +} as const; + +export const cardStyle = recipe({ + base: [ + atoms({ + borderRadius: "xl", + borderStyle: "solid", + borderWidth: 1, + px: "4", + py: "4", + width: "full", + }), + ], + variants: { + state: stateStyles, + }, +}); + +export const iconContainerStyle = recipe({ + base: [ + atoms({ + alignItems: "center", + borderRadius: "full", + display: "flex", + justifyContent: "center", + }), + { + height: "24px", + width: "24px", + flexShrink: 0, + }, + ], + variants: { + state: { + start_kyc: { background: "transparent" }, + pending: { background: "transparent" }, + rejected: { background: "transparent" }, + unknown: { background: "transparent" }, + }, + }, +}); + +export const spinnerContainerStyle = style([ + atoms({ + alignItems: "center", + borderRadius: "full", + display: "flex", + justifyContent: "center", + }), + { + height: "24px", + width: "24px", + flexShrink: 0, + }, +]); + +export const verificationModalContainerStyle = style({ + alignItems: "center", + bottom: 0, + display: "flex", + justifyContent: "center", + left: 0, + padding: "16px", + position: "fixed", + right: 0, + top: 0, + zIndex: 30, +}); + +export const verificationModalOverlayStyle = style({ + animation: `${modalFadeIn} 150ms ease`, + background: vars.color.modalOverlayBackground, + inset: 0, + position: "absolute", +}); + +export const verificationModalContentStyle = style([ + atoms({ + background: "modalBodyBackground", + }), + { + animation: `${modalScaleIn} 180ms ease`, + borderRadius: vars.borderRadius.baseContract["2xl"], + display: "flex", + flexDirection: "column", + height: "min(86vh, 720px)", + maxHeight: "calc(100vh - 32px)", + maxWidth: "760px", + overflow: "hidden", + position: "relative", + width: "100%", + zIndex: 1, + "@media": { + "screen and (max-width: 767px)": { + height: "calc(100vh - 24px)", + maxHeight: "calc(100vh - 24px)", + }, + }, + }, +]); + +export const verificationModalHeaderStyle = style([ + atoms({ + alignItems: "center", + display: "flex", + justifyContent: "space-between", + px: "4", + py: "3", + }), + { + borderBottom: `1px solid ${vars.color.backgroundMuted}`, + flexShrink: 0, + }, +]); + +export const verificationModalCloseStyle = style([ + atoms({ + alignItems: "center", + display: "flex", + justifyContent: "center", + }), + { + height: "32px", + width: "32px", + }, +]); + +export const verificationModalFrameStyle = style({ + background: "#ffffff", + border: 0, + flex: 1, + minHeight: 0, + width: "100%", +}); diff --git a/packages/widget/src/components/molecules/kyc-gate-card/verification-modal.tsx b/packages/widget/src/components/molecules/kyc-gate-card/verification-modal.tsx new file mode 100644 index 00000000..c4d40586 --- /dev/null +++ b/packages/widget/src/components/molecules/kyc-gate-card/verification-modal.tsx @@ -0,0 +1,80 @@ +import { Content, Overlay, Portal, Root, Title } from "@radix-ui/react-dialog"; +import { useContext } from "react"; +import { useTranslation } from "react-i18next"; +import { SettingsContext } from "../../../providers/settings"; +import { id } from "../../../styles/theme/ids"; +import { Box } from "../../atoms/box"; +import { XIcon } from "../../atoms/icons/x-icon"; +import { Text } from "../../atoms/typography/text"; +import { + verificationModalCloseStyle, + verificationModalContainerStyle, + verificationModalContentStyle, + verificationModalFrameStyle, + verificationModalHeaderStyle, + verificationModalOverlayStyle, +} from "./styles.css"; + +type KycVerificationModalProps = { + readonly isOpen: boolean; + readonly onOpenChange: (isOpen: boolean) => void; + readonly url: string; +}; + +export const KycVerificationModal = ({ + isOpen, + onOpenChange, + url, +}: KycVerificationModalProps) => { + const { t } = useTranslation(); + const portalContainer = useContext(SettingsContext)?.portalContainer; + + return ( + + + + onOpenChange(false)} + className={verificationModalOverlayStyle} + /> + + + + + <Text variant={{ weight: "bold", size: "large" }}> + {t("details.kyc_gate.modal.title")} + </Text> + + + onOpenChange(false)} + className={verificationModalCloseStyle} + data-testid="kyc-verification-close" + > + + + + +