stashing code
This commit is contained in:
1295
apps/frontend/src/lib/domains/currency/data/currencies.ts
Normal file
1295
apps/frontend/src/lib/domains/currency/data/currencies.ts
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/frontend/src/lib/domains/currency/data/entities.ts
Normal file
1
apps/frontend/src/lib/domains/currency/data/entities.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/currency/data/entities";
|
||||
134
apps/frontend/src/lib/domains/currency/data/repository.ts
Normal file
134
apps/frontend/src/lib/domains/currency/data/repository.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
13
apps/frontend/src/lib/domains/currency/domain/controller.ts
Normal file
13
apps/frontend/src/lib/domains/currency/domain/controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
14
apps/frontend/src/lib/domains/currency/domain/router.ts
Normal file
14
apps/frontend/src/lib/domains/currency/domain/router.ts
Normal 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();
|
||||
}),
|
||||
});
|
||||
0
apps/frontend/src/lib/domains/currency/utils.ts
Normal file
0
apps/frontend/src/lib/domains/currency/utils.ts
Normal 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> -->
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user