Initialer Laravel Commit für BetiX
This commit is contained in:
124
resources/js/composables/useAppearance.ts
Normal file
124
resources/js/composables/useAppearance.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { Appearance, ResolvedAppearance } from '@/types';
|
||||
|
||||
export type { Appearance, ResolvedAppearance };
|
||||
|
||||
export type UseAppearanceReturn = {
|
||||
appearance: Ref<Appearance>;
|
||||
resolvedAppearance: ComputedRef<ResolvedAppearance>;
|
||||
updateAppearance: (value: Appearance) => void;
|
||||
};
|
||||
|
||||
export function updateTheme(value: Appearance): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 'system') {
|
||||
const mediaQueryList = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)',
|
||||
);
|
||||
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
|
||||
|
||||
document.documentElement.classList.toggle(
|
||||
'dark',
|
||||
systemTheme === 'dark',
|
||||
);
|
||||
} else {
|
||||
document.documentElement.classList.toggle('dark', value === 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
const setCookie = (name: string, value: string, days = 365) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
|
||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
};
|
||||
|
||||
const mediaQuery = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)');
|
||||
};
|
||||
|
||||
const getStoredAppearance = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localStorage.getItem('appearance') as Appearance | null;
|
||||
};
|
||||
|
||||
const prefersDark = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const handleSystemThemeChange = () => {
|
||||
const currentAppearance = getStoredAppearance();
|
||||
|
||||
updateTheme(currentAppearance || 'system');
|
||||
};
|
||||
|
||||
export function initializeTheme(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize theme from saved preference or default to system...
|
||||
const savedAppearance = getStoredAppearance();
|
||||
updateTheme(savedAppearance || 'system');
|
||||
|
||||
// Set up system theme change listener...
|
||||
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
|
||||
}
|
||||
|
||||
const appearance = ref<Appearance>('system');
|
||||
|
||||
export function useAppearance(): UseAppearanceReturn {
|
||||
onMounted(() => {
|
||||
const savedAppearance = localStorage.getItem(
|
||||
'appearance',
|
||||
) as Appearance | null;
|
||||
|
||||
if (savedAppearance) {
|
||||
appearance.value = savedAppearance;
|
||||
}
|
||||
});
|
||||
|
||||
const resolvedAppearance = computed<ResolvedAppearance>(() => {
|
||||
if (appearance.value === 'system') {
|
||||
return prefersDark() ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return appearance.value;
|
||||
});
|
||||
|
||||
function updateAppearance(value: Appearance) {
|
||||
appearance.value = value;
|
||||
|
||||
// Store in localStorage for client-side persistence...
|
||||
localStorage.setItem('appearance', value);
|
||||
|
||||
// Store in cookie for SSR...
|
||||
setCookie('appearance', value);
|
||||
|
||||
updateTheme(value);
|
||||
}
|
||||
|
||||
return {
|
||||
appearance,
|
||||
resolvedAppearance,
|
||||
updateAppearance,
|
||||
};
|
||||
}
|
||||
59
resources/js/composables/useCurrentUrl.ts
Normal file
59
resources/js/composables/useCurrentUrl.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { InertiaLinkProps } from '@inertiajs/vue3';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import type { ComputedRef, DeepReadonly } from 'vue';
|
||||
import { computed, readonly } from 'vue';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
|
||||
export type UseCurrentUrlReturn = {
|
||||
currentUrl: DeepReadonly<ComputedRef<string>>;
|
||||
isCurrentUrl: (
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
currentUrl?: string,
|
||||
) => boolean;
|
||||
whenCurrentUrl: <T, F = null>(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
ifTrue: T,
|
||||
ifFalse?: F,
|
||||
) => T | F;
|
||||
};
|
||||
|
||||
const page = usePage();
|
||||
const currentUrlReactive = computed(
|
||||
() => new URL(page.url, window?.location.origin).pathname,
|
||||
);
|
||||
|
||||
export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
function isCurrentUrl(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
currentUrl?: string,
|
||||
) {
|
||||
const urlToCompare = currentUrl ?? currentUrlReactive.value;
|
||||
const urlString = toUrl(urlToCheck);
|
||||
|
||||
if (!urlString.startsWith('http')) {
|
||||
return urlString === urlToCompare;
|
||||
}
|
||||
|
||||
try {
|
||||
const absoluteUrl = new URL(urlString);
|
||||
|
||||
return absoluteUrl.pathname === urlToCompare;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function whenCurrentUrl(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
ifTrue: any,
|
||||
ifFalse: any = null,
|
||||
) {
|
||||
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
|
||||
}
|
||||
|
||||
return {
|
||||
currentUrl: readonly(currentUrlReactive),
|
||||
isCurrentUrl,
|
||||
whenCurrentUrl,
|
||||
};
|
||||
}
|
||||
18
resources/js/composables/useInitials.ts
Normal file
18
resources/js/composables/useInitials.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type UseInitialsReturn = {
|
||||
getInitials: (fullName?: string) => string;
|
||||
};
|
||||
|
||||
export function getInitials(fullName?: string): string {
|
||||
if (!fullName) return '';
|
||||
|
||||
const names = fullName.trim().split(' ');
|
||||
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return names[0].charAt(0).toUpperCase();
|
||||
|
||||
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function useInitials(): UseInitialsReturn {
|
||||
return { getInitials };
|
||||
}
|
||||
90
resources/js/composables/useNotifications.ts
Normal file
90
resources/js/composables/useNotifications.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export type ToastType = 'green' | 'magenta' | 'blue' | 'gold' | 'red';
|
||||
|
||||
export interface NotificationData {
|
||||
type: ToastType;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface Toast extends NotificationData {
|
||||
id: number;
|
||||
active: boolean;
|
||||
flyingOut: boolean;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface HistoryItem extends NotificationData {
|
||||
id: number;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([]);
|
||||
const history = ref<HistoryItem[]>([]);
|
||||
const unreadCount = ref(0);
|
||||
let toastId = 0;
|
||||
|
||||
export function useNotifications() {
|
||||
const notify = (data: NotificationData) => {
|
||||
// Add to toasts (popup)
|
||||
const id = toastId++;
|
||||
const toast: Toast = { id, ...data, active: false, flyingOut: false, progress: 100 };
|
||||
toasts.value.push(toast);
|
||||
|
||||
// Animation logic for toast
|
||||
setTimeout(() => {
|
||||
const t = toasts.value.find(x => x.id === id);
|
||||
if(t) t.active = true;
|
||||
// Trigger lucide icons update if needed in component
|
||||
}, 50);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const t = toasts.value.find(x => x.id === id);
|
||||
if (!t) { clearInterval(interval); return; }
|
||||
t.progress -= 0.5;
|
||||
if (t.progress <= 0) { clearInterval(interval); closeToast(id); }
|
||||
}, 40);
|
||||
|
||||
// Add to history
|
||||
history.value.unshift({
|
||||
id: Date.now() + Math.random(),
|
||||
...data,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
});
|
||||
unreadCount.value++;
|
||||
};
|
||||
|
||||
const closeToast = (id: number) => {
|
||||
const t = toasts.value.find(x => x.id === id);
|
||||
if (t) {
|
||||
t.flyingOut = true;
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter(x => x.id !== id);
|
||||
}, 600);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = () => {
|
||||
history.value.forEach(n => n.read = true);
|
||||
unreadCount.value = 0;
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
history.value = [];
|
||||
unreadCount.value = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
toasts,
|
||||
history,
|
||||
unreadCount,
|
||||
notify,
|
||||
closeToast,
|
||||
markAllRead,
|
||||
clearAll
|
||||
};
|
||||
}
|
||||
129
resources/js/composables/usePrimaryColor.ts
Normal file
129
resources/js/composables/usePrimaryColor.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
export type UsePrimaryColorReturn = {
|
||||
primaryColor: ReturnType<typeof ref<string>>;
|
||||
updatePrimaryColor: (value: string) => void;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'primaryColor';
|
||||
|
||||
function setCookie(name: string, value: string, days = 365) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
}
|
||||
|
||||
function getStoredPrimaryColor(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
function parseColorToRGB(color: string): { r: number; g: number; b: number } | null {
|
||||
// Supports #rgb, #rrggbb, rgb(), hsl()
|
||||
color = color.trim().toLowerCase();
|
||||
|
||||
// Hex
|
||||
if (color.startsWith('#')) {
|
||||
let hex = color.slice(1);
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split('').map((c) => c + c).join('');
|
||||
}
|
||||
if (hex.length === 6) {
|
||||
const num = parseInt(hex, 16);
|
||||
return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
|
||||
}
|
||||
}
|
||||
|
||||
// rgb/rgba
|
||||
if (color.startsWith('rgb')) {
|
||||
const m = color.match(/rgba?\(([^)]+)\)/);
|
||||
if (m) {
|
||||
const [r, g, b] = m[1]
|
||||
.split(',')
|
||||
.slice(0, 3)
|
||||
.map((v) => parseFloat(v.trim()));
|
||||
return { r, g, b };
|
||||
}
|
||||
}
|
||||
|
||||
// hsl/hsla
|
||||
if (color.startsWith('hsl')) {
|
||||
const m = color.match(/hsla?\(([^)]+)\)/);
|
||||
if (m) {
|
||||
const [hStr, sStr, lStr] = m[1].split(',').map((v) => v.trim());
|
||||
const h = parseFloat(hStr);
|
||||
const s = parseFloat(sStr) / 100;
|
||||
const l = parseFloat(lStr) / 100;
|
||||
// convert HSL to RGB
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m2 = l - c / 2;
|
||||
let r1 = 0, g1 = 0, b1 = 0;
|
||||
if (0 <= h && h < 60) [r1, g1, b1] = [c, x, 0];
|
||||
else if (60 <= h && h < 120) [r1, g1, b1] = [x, c, 0];
|
||||
else if (120 <= h && h < 180) [r1, g1, b1] = [0, c, x];
|
||||
else if (180 <= h && h < 240) [r1, g1, b1] = [0, x, c];
|
||||
else if (240 <= h && h < 300) [r1, g1, b1] = [x, 0, c];
|
||||
else [r1, g1, b1] = [c, 0, x];
|
||||
return {
|
||||
r: Math.round((r1 + m2) * 255),
|
||||
g: Math.round((g1 + m2) * 255),
|
||||
b: Math.round((b1 + m2) * 255),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getContrastColor(bg: string): '#000000' | '#FFFFFF' {
|
||||
const rgb = parseColorToRGB(bg) || { r: 0, g: 0, b: 0 };
|
||||
// Relative luminance
|
||||
const srgb = [rgb.r, rgb.g, rgb.b].map((v) => {
|
||||
const c = v / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
const L = 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
|
||||
return L > 0.179 ? '#000000' : '#FFFFFF';
|
||||
}
|
||||
|
||||
export function applyPrimaryColor(value: string): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply to global primary variables used by Tailwind theme tokens
|
||||
root.style.setProperty('--primary', value);
|
||||
root.style.setProperty('--primary-foreground', getContrastColor(value));
|
||||
|
||||
// Align commonly used accent variables in user layout to the same main color
|
||||
root.style.setProperty('--magenta', value);
|
||||
root.style.setProperty('--sidebar-primary', value);
|
||||
root.style.setProperty('--sidebar-primary-foreground', getContrastColor(value));
|
||||
}
|
||||
|
||||
export function initializePrimaryColor(): void {
|
||||
const saved = getStoredPrimaryColor();
|
||||
if (saved) applyPrimaryColor(saved);
|
||||
}
|
||||
|
||||
const primaryColorRef = ref<string>(getStoredPrimaryColor() || '#ff007a');
|
||||
|
||||
export function usePrimaryColor(): UsePrimaryColorReturn {
|
||||
onMounted(() => {
|
||||
const saved = getStoredPrimaryColor();
|
||||
if (saved) {
|
||||
primaryColorRef.value = saved;
|
||||
}
|
||||
});
|
||||
|
||||
function updatePrimaryColor(value: string) {
|
||||
primaryColorRef.value = value;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
}
|
||||
setCookie(STORAGE_KEY, value);
|
||||
applyPrimaryColor(value);
|
||||
}
|
||||
|
||||
return { primaryColor: primaryColorRef, updatePrimaryColor };
|
||||
}
|
||||
120
resources/js/composables/useTwoFactorAuth.ts
Normal file
120
resources/js/composables/useTwoFactorAuth.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||
|
||||
export type UseTwoFactorAuthReturn = {
|
||||
qrCodeSvg: Ref<string | null>;
|
||||
manualSetupKey: Ref<string | null>;
|
||||
recoveryCodesList: Ref<string[]>;
|
||||
errors: Ref<string[]>;
|
||||
hasSetupData: ComputedRef<boolean>;
|
||||
clearSetupData: () => void;
|
||||
clearErrors: () => void;
|
||||
clearTwoFactorAuthData: () => void;
|
||||
fetchQrCode: () => Promise<void>;
|
||||
fetchSetupKey: () => Promise<void>;
|
||||
fetchSetupData: () => Promise<void>;
|
||||
fetchRecoveryCodes: () => Promise<void>;
|
||||
};
|
||||
|
||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
const manualSetupKey = ref<string | null>(null);
|
||||
const qrCodeSvg = ref<string | null>(null);
|
||||
const recoveryCodesList = ref<string[]>([]);
|
||||
|
||||
const hasSetupData = computed<boolean>(
|
||||
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
|
||||
);
|
||||
|
||||
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
|
||||
const fetchQrCode = async (): Promise<void> => {
|
||||
try {
|
||||
const { svg } = await fetchJson<{ svg: string; url: string }>(
|
||||
qrCode.url(),
|
||||
);
|
||||
|
||||
qrCodeSvg.value = svg;
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch QR code');
|
||||
qrCodeSvg.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupKey = async (): Promise<void> => {
|
||||
try {
|
||||
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
|
||||
secretKey.url(),
|
||||
);
|
||||
|
||||
manualSetupKey.value = key;
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch a setup key');
|
||||
manualSetupKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSetupData = (): void => {
|
||||
manualSetupKey.value = null;
|
||||
qrCodeSvg.value = null;
|
||||
clearErrors();
|
||||
};
|
||||
|
||||
const clearErrors = (): void => {
|
||||
errors.value = [];
|
||||
};
|
||||
|
||||
const clearTwoFactorAuthData = (): void => {
|
||||
clearSetupData();
|
||||
clearErrors();
|
||||
recoveryCodesList.value = [];
|
||||
};
|
||||
|
||||
const fetchRecoveryCodes = async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
recoveryCodesList.value = await fetchJson<string[]>(
|
||||
recoveryCodes.url(),
|
||||
);
|
||||
} catch {
|
||||
errors.value.push('Failed to fetch recovery codes');
|
||||
recoveryCodesList.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSetupData = async (): Promise<void> => {
|
||||
try {
|
||||
clearErrors();
|
||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
||||
} catch {
|
||||
qrCodeSvg.value = null;
|
||||
manualSetupKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
qrCodeSvg,
|
||||
manualSetupKey,
|
||||
recoveryCodesList,
|
||||
errors,
|
||||
hasSetupData,
|
||||
clearSetupData,
|
||||
clearErrors,
|
||||
clearTwoFactorAuthData,
|
||||
fetchQrCode,
|
||||
fetchSetupKey,
|
||||
fetchSetupData,
|
||||
fetchRecoveryCodes,
|
||||
};
|
||||
};
|
||||
180
resources/js/composables/useVault.ts
Normal file
180
resources/js/composables/useVault.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ref } from 'vue';
|
||||
import { csrfFetch } from '@/utils/csrfFetch';
|
||||
|
||||
export interface VaultBalances {
|
||||
balance: string;
|
||||
vault_balance: string;
|
||||
vault_balances: Record<string, string>;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
export function useVault() {
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const balances = ref<VaultBalances | null>(null);
|
||||
const transfers = ref<any>(null);
|
||||
const pinRequired = ref(false);
|
||||
const lockedUntil = ref<string | null>(null);
|
||||
|
||||
const sessionPin = ref<string | null>(null);
|
||||
|
||||
async function load(perPage = 10) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const resp = await fetch(`/api/wallet/vault?per_page=${perPage}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const json = await resp.json();
|
||||
balances.value = {
|
||||
balance: json.balance,
|
||||
vault_balance: json.vault_balance,
|
||||
vault_balances: json.vault_balances ?? { BTX: json.vault_balance },
|
||||
currency: json.currency,
|
||||
};
|
||||
transfers.value = json.transfers;
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Failed to load vault';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function uuidv4(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return (crypto as any).randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
async function deposit(amount: string, currency = 'BTX', pin?: string) {
|
||||
return doAction('/api/wallet/vault/deposit', amount, currency, pin);
|
||||
}
|
||||
|
||||
async function withdraw(amount: string, currency = 'BTX', pin?: string) {
|
||||
return doAction('/api/wallet/vault/withdraw', amount, currency, pin);
|
||||
}
|
||||
|
||||
async function doAction(url: string, amount: string, currency: string, pin?: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
pinRequired.value = false;
|
||||
lockedUntil.value = null;
|
||||
|
||||
try {
|
||||
const usePin = pin ?? sessionPin.value;
|
||||
if (!usePin) {
|
||||
pinRequired.value = true;
|
||||
throw new Error('PIN required');
|
||||
}
|
||||
|
||||
const body = { amount, currency, pin: usePin, idempotency_key: uuidv4() };
|
||||
const resp = await csrfFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (resp.status === 423) {
|
||||
const j = await resp.json().catch(() => ({}));
|
||||
pinRequired.value = true;
|
||||
lockedUntil.value = j?.locked_until || null;
|
||||
throw new Error(j?.message || 'PIN required');
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const j = await resp.json().catch(() => ({}));
|
||||
throw new Error(j?.message || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
const json = await resp.json();
|
||||
|
||||
if (json?.balances) {
|
||||
balances.value = {
|
||||
balance: json.balances.balance,
|
||||
vault_balance: json.balances.vault_balance,
|
||||
vault_balances: json.balances.vault_balances ?? { BTX: json.balances.vault_balance },
|
||||
currency: balances.value?.currency || 'BTX',
|
||||
};
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
return json;
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Vault action failed';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPin(pin: string) {
|
||||
error.value = null;
|
||||
try {
|
||||
const resp = await csrfFetch('/api/wallet/vault/pin/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ pin }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const j = await resp.json().catch(() => ({}));
|
||||
if (resp.status === 423) lockedUntil.value = j?.locked_until || null;
|
||||
throw new Error(j?.message || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
sessionPin.value = pin;
|
||||
pinRequired.value = false;
|
||||
lockedUntil.value = null;
|
||||
|
||||
return await resp.json();
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'PIN verification failed';
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionPin() {
|
||||
sessionPin.value = null;
|
||||
}
|
||||
|
||||
async function setPin(pin: string, current_pin?: string) {
|
||||
error.value = null;
|
||||
const payload: any = { pin };
|
||||
if (current_pin) payload.current_pin = current_pin;
|
||||
|
||||
const resp = await csrfFetch('/api/wallet/vault/pin/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const j = await resp.json().catch(() => ({}));
|
||||
throw new Error(j?.message || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
sessionPin.value = pin;
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
balances,
|
||||
transfers,
|
||||
pinRequired,
|
||||
lockedUntil,
|
||||
load,
|
||||
deposit,
|
||||
withdraw,
|
||||
verifyPin,
|
||||
setPin,
|
||||
clearSessionPin,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user