stashing code

This commit is contained in:
user
2025-10-20 17:07:41 +03:00
commit f5b99afc8f
890 changed files with 54823 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/currency/data/entities";

View File

@@ -0,0 +1,134 @@
import { currencyModel, type Currency } from "./entities";
import { ERROR_CODES, type Result } from "@pkg/result";
import { CURRENCIES } from "$lib/domains/currency/data/currencies";
import type Redis from "ioredis";
import { betterFetch } from "@better-fetch/fetch";
import { getError, Logger } from "@pkg/logger";
export class CurrencyRepository {
private store: Redis;
private key = "currency:exchange:rates";
constructor(redis: Redis) {
this.store = redis;
}
private async getRates(): Promise<Result<Record<string, number>>> {
try {
const found = await this.store.get(this.key);
if (!found) {
return {};
}
return { data: JSON.parse(found) };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.API_ERROR,
message: "Failed to fetch currency exchange rates",
detail: "Failed to retrieve currency exchange rates from cache",
userHint: "Try again later?",
actionable: false,
},
err,
),
};
}
}
async getCurrencies(depth = 1): Promise<Result<Currency[]>> {
let ratesRes = await this.getRates();
if (ratesRes.error) {
return { error: ratesRes.error };
}
if (!ratesRes.data) {
await this.refreshCurrenciesRate();
if (depth > 3) {
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Could not get currency info at this moment",
detail: "Max retries reached in trying to refresh currency exchange rates",
userHint: "Try again later?",
actionable: false,
}),
};
}
return this.getCurrencies(depth++);
}
const rates = ratesRes.data;
const out = new Array<Currency>();
let i = 0;
for (const cCode of Object.keys(rates)) {
i++;
const found = CURRENCIES.find((c) => c.code === cCode);
const exchangeRate = rates[cCode];
const ratio = Math.round((1 / exchangeRate) * 10 ** 6) / 10 ** 6;
const parsed = currencyModel.safeParse({
id: i,
code: cCode.toUpperCase(),
currency:
found && found.currency
? `${cCode} (${found?.currency})`
: "Unknown",
exchangeRate,
ratio,
});
if (parsed.error) {
continue;
}
out.push(parsed.data);
}
return { data: out };
}
async refreshCurrenciesRate(): Promise<Result<boolean>> {
const response = await betterFetch<{
result: string;
provider: string;
documentation: string;
terms_of_use: string;
time_last_update_unix: number;
time_last_update_utc: string;
time_next_update_unix: number;
time_next_update_utc: string;
time_eol_unix: number;
base_code: string;
rates: Record<string, number>;
}>("https://open.er-api.com/v6/latest/USD");
if (response.error) {
return {
error: getError({
code: ERROR_CODES.API_ERROR,
message: "Failed to fetch currency exchange rates",
detail:
response.error.message ??
"Unknown error while fetching currency exchange rates",
userHint: "Try again later?",
actionable: false,
}),
};
}
const ONE_DAY_IN_SECONDS = 86400;
if (response.data) {
Logger.info(`Updating currency exchange rates to the new one`);
await this.store.del(this.key);
await this.store.setex(
this.key,
// For 24 hours, it'll be enough
ONE_DAY_IN_SECONDS,
JSON.stringify(response.data.rates),
);
}
return { data: false };
}
}

View File

@@ -0,0 +1,13 @@
import type { CurrencyRepository } from "../data/repository";
export class CurrencyController {
private repo: CurrencyRepository;
constructor(repo: CurrencyRepository) {
this.repo = repo;
}
async getCurrencies() {
return this.repo.getCurrencies();
}
}

View File

@@ -0,0 +1,14 @@
import { createTRPCRouter } from "$lib/trpc/t";
import { publicProcedure } from "$lib/server/trpc/t";
import { CurrencyController } from "./controller";
import { CurrencyRepository } from "../data/repository";
import { getRedisInstance } from "$lib/server/redis";
export const currencyRouter = createTRPCRouter({
getCurrencies: publicProcedure.query(async ({ input, ctx }) => {
const cc = new CurrencyController(
new CurrencyRepository(getRedisInstance()),
);
return cc.getCurrencies();
}),
});

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button";
import { capitalize } from "$lib/core/string.utils";
import { cn } from "$lib/utils";
import { currencyStore, currencyVM } from "./currency.vm.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import Title from "$lib/components/atoms/title.svelte";
import { TRANSITION_ALL } from "$lib/core/constants";
import Input from "$lib/components/ui/input/input.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import GlobeIcon from "~icons/solar/earth-outline";
let { invert }: { invert: boolean } = $props();
</script>
<Dialog.Root>
<Dialog.Trigger
class={buttonVariants({ variant: invert ? "glassWhite" : "ghost" })}
>
<Icon icon={GlobeIcon} cls="w-auto h-6" />
{$currencyStore.code}
</Dialog.Trigger>
<Dialog.Content class="max-w-7xl">
<Title size="h4" weight="semibold">Currency Select</Title>
<Input
placeholder="Search"
class="w-full md:w-1/3 lg:w-1/4"
bind:value={currencyVM.query}
oninput={() => {
currencyVM.applyQuery();
}}
/>
<div
class="grid max-h-[70vh] grid-cols-2 gap-2 overflow-y-auto p-4 md:grid-cols-3 md:gap-4 md:p-8 lg:grid-cols-4"
>
{#each currencyVM.queriedCurrencies as each}
<button
class={cn(
"flex w-full flex-col items-start gap-2 rounded-lg border-2 border-transparent bg-white/50 p-4 text-start shadow-sm hover:bg-gray-100",
each.code === $currencyStore.code
? "border-brand-400 bg-brand-50 shadow-md"
: "",
TRANSITION_ALL,
)}
onclick={() => {
currencyVM.setCurrency(each.code);
}}
>
<small>{capitalize(each.currency)}</small>
<strong>{each.code.toUpperCase()}</strong>
</button>
{/each}
</div>
</Dialog.Content>
</Dialog.Root>
<!-- <LabelWrapper label="Currency"> -->
<!-- <Select.Root -->
<!-- type="single" -->
<!-- required -->
<!-- onValueChange={(code) => { -->
<!-- currencyVM.setCurrency(code); -->
<!-- }} -->
<!-- name="role" -->
<!-- > -->
<!-- <Select.Trigger> -->
<!-- {capitalize($currencyStore.currency ?? "select")} -->
<!-- </Select.Trigger> -->
<!-- <Select.Content> -->
<!-- {#each currencyVM.currencies as each} -->
<!-- <Select.Item value={each.code}> -->
<!-- {capitalize(each.currency)} -->
<!-- </Select.Item> -->
<!-- {/each} -->
<!-- </Select.Content> -->
<!-- </Select.Root> -->
<!-- </LabelWrapper> -->

View File

@@ -0,0 +1,109 @@
import { get, writable } from "svelte/store";
import type { Currency } from "../data/entities";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
export const currencyStore = writable<Currency>({
id: 0,
code: "USD",
currency: "USD (US Dollar)",
exchangeRate: 1,
ratio: 1,
});
class CurrencyViewModel {
currencies = $state<Currency[]>([]);
queriedCurrencies = $state<Currency[]>([]);
query = $state<string>("");
private applyTimeout: ReturnType<typeof setTimeout> | undefined;
loadCachedSelection() {
const stored = localStorage.getItem("currency");
if (stored) {
const info = JSON.parse(stored);
if (info) {
currencyStore.set(info);
}
}
}
private cacheSelection(info: Currency) {
localStorage.setItem("currency", JSON.stringify(info));
}
async getCurrencies() {
const api = get(trpcApiStore);
if (!api) {
return;
}
const out = await api.currency.getCurrencies.query();
if (out.error) {
toast.error(out.error.message, { description: out.error.userHint });
}
if (out.data) {
this.currencies = out.data;
this._applyQuery();
}
}
applyQuery() {
if (this.applyTimeout) {
clearTimeout(this.applyTimeout);
}
this.applyTimeout = setTimeout(() => {
this._applyQuery();
}, 500);
}
private _applyQuery() {
if (this.query.length < 2) {
this.queriedCurrencies = this.currencies;
return;
}
const queryNormalized = this.query.toLowerCase();
this.queriedCurrencies = this.currencies.filter((c) => {
return c.currency.toLowerCase().includes(queryNormalized);
});
}
setCurrency(code: string) {
const found = this.currencies.find((c) => c.code === code);
if (found) {
currencyStore.set(found);
this.cacheSelection(found);
}
setTimeout(() => {
window.location.reload();
}, 500);
}
convertToUsd(amount: number, currencyCode: string) {
const currency = this.currencies.find((c) => c.code === currencyCode);
if (!currency) {
return 0;
}
return amount / currency.exchangeRate;
}
convertFromUsd(amount: number, currencyCode: string) {
const currency = this.currencies.find((c) => c.code === currencyCode);
if (!currency) {
return 0;
}
return amount * currency.exchangeRate;
}
}
export const currencyVM = new CurrencyViewModel();
export function convertAndFormatCurrency(amount: number) {
const code = get(currencyStore).code;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: code,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(currencyVM.convertFromUsd(amount, code));
}