refactor: organize components

This commit is contained in:
Jefferson Mantovani 2025-10-10 11:39:54 -03:00
parent 2b6be86b2e
commit 84afed78fb
18 changed files with 1617 additions and 78 deletions

View File

@ -4,6 +4,8 @@ import { ref, watch, onMounted, computed } from "vue";
import { debounce } from "@/utils/debounce";
import { decimalCount } from "@/utils/decimalCount";
import { useFloating, arrow, offset, flip, shift } from "@floating-ui/vue";
import IconButton from "../ui/IconButton.vue";
import withdrawIcon from "@/assets/withdraw.svg?url";
const props = defineProps<{
validDeposits: ValidDeposit[];
@ -16,8 +18,6 @@ const emit = defineEmits<{
}>();
const withdrawAmount = ref<string>("");
const withdrawButtonOpacity = ref<number>(0.6);
const withdrawButtonCursor = ref<string>("not-allowed");
const isCollapsibleOpen = ref<boolean>(false);
const validDecimals = ref<boolean>(true);
const validWithdrawAmount = ref<boolean>(true);
@ -77,26 +77,6 @@ const cancelWithdraw = () => {
enableConfirmButton.value = false;
};
watch(enableConfirmButton, (): void => {
if (!enableConfirmButton.value) {
withdrawButtonOpacity.value = 0.7;
withdrawButtonCursor.value = "not-allowed";
} else {
withdrawButtonOpacity.value = 1;
withdrawButtonCursor.value = "pointer";
}
});
watch(withdrawAmount, (): void => {
if (!withdrawAmount.value || !enableConfirmButton.value) {
withdrawButtonOpacity.value = 0.7;
withdrawButtonCursor.value = "not-allowed";
} else {
withdrawButtonOpacity.value = 1;
withdrawButtonCursor.value = "pointer";
}
});
onMounted(() => {
useFloating(reference, floating, {
placement: "right",
@ -150,17 +130,14 @@ onMounted(() => {
</div>
</div>
<div v-show="!isCollapsibleOpen" class="flex justify-end items-center">
<div
class="flex gap-2 cursor-pointer items-center justify-self-center border-2 p-2 border-amber-300 rounded-md"
<IconButton
text="Sacar"
:icon="withdrawIcon"
variant="outline"
size="md"
:full-width="false"
@click="openWithdrawForm"
>
<img
alt="Withdraw image"
src="@/assets/withdraw.svg?url"
class="w-3 h-3 sm:w-4 sm:h-4"
/>
<span class="last-release-info">Sacar</span>
</div>
/>
</div>
</div>
<div class="pt-5">
@ -191,22 +168,20 @@ onMounted(() => {
>
<h1
@click="cancelWithdraw"
class="text-black font-medium cursor-pointer"
class="text-black font-medium cursor-pointer hover:text-gray-600 transition-colors"
>
Cancelar
</h1>
<div
class="withdraw-button flex gap-2 items-center justify-self-center border-2 p-2 border-amber-300 rounded-md"
<IconButton
text="Sacar"
:icon="withdrawIcon"
variant="outline"
size="md"
:full-width="false"
:disabled="!enableConfirmButton"
@click="callWithdraw"
>
<img
alt="Withdraw image"
src="@/assets/withdraw.svg?url"
class="w-3 h-3 sm:w-4 sm:h-4"
/>
<span class="last-release-info">Sacar</span>
</div>
/>
</div>
</div>
</div>
@ -217,19 +192,10 @@ p {
@apply text-gray-900;
}
.last-release-info {
@apply font-medium text-xs sm:text-sm text-gray-900 justify-self-center;
}
.tooltip {
@apply bg-white text-gray-900 font-medium text-xs md:text-base px-3 py-2 rounded border-2 border-emerald-500 left-5 top-[-3rem];
}
.withdraw-button {
opacity: v-bind(withdrawButtonOpacity);
cursor: v-bind(withdrawButtonCursor);
}
input[type="number"] {
appearance: textfield;
-moz-appearance: textfield;

View File

@ -97,14 +97,14 @@ showInitialItems();
>
<button
type="button"
class="text-white font-semibold border-2 border-amber-300 rounded-lg px-4 py-2 hover:bg-amber-300/10 transition-colors"
class="text-white font-semibold border-2 border-amber-300 rounded-lg px-4 py-2 hover:bg-amber-300/10 transition-colors cursor-pointer"
@click="loadMore()"
>
Carregar mais
</button>
<span class="text-gray-300">
({{ itemsToShow.length }} de {{ props.walletTransactions.length }}
transações )
<span class="text-gray-300 text-sm">
{{ itemsToShow.length }} de {{ props.walletTransactions.length }}
transações
</span>
</div>

View File

@ -2,6 +2,7 @@
import type { WalletTransaction } from "@/model/WalletTransaction";
import { NetworkEnum, TokenEnum } from "@/model/NetworkEnum";
import { computed } from "vue";
import StatusBadge, { type StatusType } from "../ui/StatusBadge.vue";
const props = defineProps<{
transaction: WalletTransaction;
@ -30,20 +31,20 @@ const explorerName = computed(() => {
return props.networkName === NetworkEnum.sepolia ? "Etherscan" : "Polygonscan";
});
const statusInfo = computed(() => {
const statusType = computed((): StatusType => {
if (eventName.value === "Reserva") {
switch (props.transaction.lockStatus) {
case 1:
return { text: "Em Aberto", color: "bg-amber-300" };
return "open";
case 2:
return { text: "Expirado", color: "bg-[#94A3B8]" };
return "expired";
case 3:
return { text: "Finalizado", color: "bg-emerald-300" };
return "completed";
default:
return { text: "Finalizado", color: "bg-emerald-300" };
return "completed";
}
}
return { text: "Finalizado", color: "bg-emerald-300" };
return "completed";
});
const showExplorerLink = computed(() => {
@ -71,8 +72,8 @@ const handleExplorerClick = () => {
</span>
</div>
<div class="flex flex-col items-center justify-center">
<div :class="[statusInfo.color, 'status-text']">
{{ statusInfo.text }}
<div class="mb-2 mt-4">
<StatusBadge :status="statusType" />
</div>
<div
v-if="showExplorerLink"
@ -111,10 +112,6 @@ const handleExplorerClick = () => {
@apply flex justify-between items-center;
}
.status-text {
@apply text-xs sm:text-base font-medium text-gray-900 rounded-lg text-center mb-2 px-2 py-1 mt-4;
}
.last-release-info {
@apply font-medium text-xs sm:text-sm text-gray-900 justify-self-center;
}

View File

@ -0,0 +1,208 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { TokenEnum } from "@/model/NetworkEnum";
import { decimalCount } from "@/utils/decimalCount";
import { debounce } from "@/utils/debounce";
import TokenSelector from "./TokenSelector.vue";
import ErrorMessage from "./ErrorMessage.vue";
const props = withDefaults(
defineProps<{
modelValue: number;
selectedToken: TokenEnum;
placeholder?: string;
showTokenSelector?: boolean;
showConversion?: boolean;
conversionRate?: number;
minValue?: number;
maxValue?: number;
disabled?: boolean;
required?: boolean;
}>(),
{
placeholder: "0",
showTokenSelector: true,
showConversion: true,
conversionRate: 1,
minValue: 0,
disabled: false,
required: false,
}
);
const emit = defineEmits<{
"update:modelValue": [value: number];
"update:selectedToken": [token: TokenEnum];
error: [message: string | null];
valid: [isValid: boolean];
}>();
const inputValue = ref<string>(String(props.modelValue || ""));
const validDecimals = ref(true);
const validRange = ref(true);
const convertedValue = computed(() => {
return (props.modelValue * props.conversionRate).toFixed(2);
});
const errorMessage = computed(() => {
if (!validDecimals.value) {
return "Por favor utilize no máximo 2 casas decimais";
}
if (!validRange.value) {
if (props.minValue && props.modelValue < props.minValue) {
return `Valor mínimo: ${props.minValue}`;
}
if (props.maxValue && props.modelValue > props.maxValue) {
return `Valor máximo: ${props.maxValue}`;
}
}
return null;
});
const isValid = computed(() => {
return validDecimals.value && validRange.value;
});
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
const value = target.value;
inputValue.value = value;
const numValue = Number(value);
// Validar decimais
if (decimalCount(value) > 2) {
validDecimals.value = false;
emit("error", "Por favor utilize no máximo 2 casas decimais");
emit("valid", false);
return;
}
validDecimals.value = true;
// Validar range
if (props.minValue !== undefined && numValue < props.minValue) {
validRange.value = false;
emit("error", `Valor mínimo: ${props.minValue}`);
emit("valid", false);
return;
}
if (props.maxValue !== undefined && numValue > props.maxValue) {
validRange.value = false;
emit("error", `Valor máximo: ${props.maxValue}`);
emit("valid", false);
return;
}
validRange.value = true;
emit("update:modelValue", numValue);
emit("error", null);
emit("valid", true);
};
const debouncedHandleInput = debounce(handleInput, 500);
const handleTokenChange = (token: TokenEnum) => {
emit("update:selectedToken", token);
};
watch(() => props.modelValue, (newVal) => {
if (newVal !== Number(inputValue.value)) {
inputValue.value = String(newVal || "");
}
});
</script>
<template>
<div class="amount-input-container">
<div class="input-row">
<input
type="number"
:value="inputValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
class="amount-input"
:class="{
'font-semibold text-xl': modelValue > 0,
'has-error': !isValid,
}"
step="0.01"
@input="debouncedHandleInput"
/>
<TokenSelector
v-if="showTokenSelector"
:model-value="selectedToken"
:disabled="disabled"
size="md"
@update:model-value="handleTokenChange"
/>
<div v-else class="token-display">
{{ selectedToken }}
</div>
</div>
<div class="divider"></div>
<div class="info-row">
<p v-if="showConversion" class="conversion-text">
~ R$ {{ convertedValue }}
</p>
<slot name="extra-info"></slot>
</div>
<ErrorMessage
v-if="errorMessage"
:message="errorMessage"
type="error"
/>
</div>
</template>
<style scoped>
.amount-input-container {
@apply flex flex-col w-full gap-2;
}
.input-row {
@apply flex justify-between items-center w-full gap-4;
}
.amount-input {
@apply border-none outline-none text-lg text-gray-900 flex-1 bg-transparent;
appearance: textfield;
-moz-appearance: textfield;
}
.amount-input::-webkit-inner-spin-button,
.amount-input::-webkit-outer-spin-button {
-webkit-appearance: none;
}
.amount-input:disabled {
@apply opacity-50 cursor-not-allowed;
}
.amount-input.has-error {
@apply text-red-500;
}
.token-display {
@apply flex items-center px-3 py-2 bg-gray-300 rounded-3xl min-w-fit text-gray-900 font-medium;
}
.divider {
@apply w-full border-b border-gray-300 my-2;
}
.info-row {
@apply flex justify-between items-center;
}
.conversion-text {
@apply text-gray-500 font-normal text-sm;
}
</style>

View File

@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed } from "vue";
import bankList from "@/utils/files/isbpList.json";
export interface Bank {
ISPB: string;
longName: string;
}
const props = withDefaults(
defineProps<{
modelValue: string | null;
disabled?: boolean;
placeholder?: string;
}>(),
{
disabled: false,
placeholder: "Busque e selecione seu banco",
}
);
const emit = defineEmits<{
"update:modelValue": [value: string];
change: [bank: Bank];
}>();
const bankItems = computed(() => {
return bankList.map((bank) => ({
value: bank.ISPB,
label: bank.longName,
bank: bank,
}));
});
const selectedItem = computed(() => {
if (!props.modelValue) return null;
return bankItems.value.find((item) => item.value === props.modelValue);
});
const searchQuery = computed({
get: () => selectedItem.value?.label || "",
set: (value: string) => {
// Handled by input
},
});
const filteredBanks = computed(() => {
if (!searchQuery.value) return [];
const query = searchQuery.value.toLowerCase();
return bankList
.filter((bank) => bank.longName.toLowerCase().includes(query))
.slice(0, 10);
});
const showBankList = computed(() => {
return filteredBanks.value.length > 0 && searchQuery.value.length > 0;
});
const selectBank = (bank: Bank) => {
emit("update:modelValue", bank.ISPB);
emit("change", bank);
};
</script>
<template>
<div class="bank-selector">
<input
type="text"
v-model="searchQuery"
:placeholder="placeholder"
:disabled="disabled"
class="bank-input"
autocomplete="off"
/>
<transition name="dropdown-fade">
<div v-if="showBankList" class="bank-list">
<div
v-for="bank in filteredBanks"
:key="bank.ISPB"
class="bank-item"
@click="selectBank(bank)"
>
<span class="bank-name">{{ bank.longName }}</span>
<span class="bank-ispb">{{ bank.ISPB }}</span>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
.bank-selector {
@apply relative w-full;
}
.bank-input {
@apply w-full px-4 py-3 border-none outline-none rounded-lg bg-white text-gray-900 text-sm;
}
.bank-input:focus {
@apply ring-2 ring-indigo-800;
}
.bank-input:disabled {
@apply opacity-50 cursor-not-allowed bg-gray-100;
}
.bank-list {
@apply absolute top-full left-0 right-0 mt-2 bg-white rounded-lg border border-gray-300 shadow-lg z-50 max-h-64 overflow-y-auto;
}
.bank-item {
@apply flex justify-between items-center px-4 py-3 cursor-pointer hover:bg-gray-100 transition-colors;
}
.bank-name {
@apply text-gray-900 font-medium text-sm flex-1;
}
.bank-ispb {
@apply text-gray-500 text-xs ml-2;
}
/* Animação */
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
@apply transition-all duration-200;
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
@apply opacity-0 -translate-y-2;
}
</style>

View File

@ -1,26 +1,161 @@
<script setup lang="ts">
const props = defineProps({
text: String,
isDisabled: Boolean,
});
export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ButtonSize = "sm" | "md" | "lg" | "xl";
const props = withDefaults(
defineProps<{
text: string;
isDisabled?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
icon?: string;
iconPosition?: "left" | "right";
fullWidth?: boolean;
loading?: boolean;
}>(),
{
isDisabled: false,
variant: "primary",
size: "xl",
iconPosition: "left",
fullWidth: true,
loading: false,
}
);
const emit = defineEmits(["buttonClicked"]);
const handleClick = () => {
if (!props.isDisabled && !props.loading) {
emit("buttonClicked");
}
};
</script>
<template>
<button
type="button"
class="button"
@click="emit('buttonClicked')"
v-bind:class="{ 'opacity-70': props.isDisabled }"
:disabled="props.isDisabled ? props.isDisabled : false"
:class="[
'button',
`variant-${variant}`,
`size-${size}`,
{ 'is-disabled': isDisabled || loading, 'full-width': fullWidth },
]"
:disabled="isDisabled || loading"
@click="handleClick"
>
{{ props.text }}
<span v-if="loading" class="loader"></span>
<template v-else>
<img
v-if="icon && iconPosition === 'left'"
:src="icon"
:alt="`${text} icon`"
class="button-icon"
/>
<span class="button-text">{{ text }}</span>
<img
v-if="icon && iconPosition === 'right'"
:src="icon"
:alt="`${text} icon`"
class="button-icon"
/>
</template>
</button>
</template>
<style scoped>
.button {
@apply rounded-lg w-full text-base font-semibold text-gray-900 p-4 bg-amber-400;
@apply rounded-lg font-semibold transition-all duration-200 cursor-pointer flex items-center justify-center gap-2;
}
.button:hover:not(.is-disabled) {
@apply transform scale-[1.02];
}
.button.is-disabled {
@apply opacity-70 cursor-not-allowed;
}
.button.full-width {
@apply w-full;
}
/* Variantes */
.variant-primary {
@apply bg-amber-400 text-gray-900 border-2 border-amber-400;
}
.variant-primary:hover:not(.is-disabled) {
@apply bg-amber-500 border-amber-500;
}
.variant-secondary {
@apply bg-gray-200 text-gray-900 border-2 border-gray-300;
}
.variant-secondary:hover:not(.is-disabled) {
@apply bg-gray-300 border-gray-400;
}
.variant-outline {
@apply bg-transparent text-gray-900 border-2 border-amber-400;
}
.variant-outline:hover:not(.is-disabled) {
@apply bg-amber-400/10;
}
.variant-ghost {
@apply bg-transparent text-gray-900 border-2 border-transparent;
}
.variant-ghost:hover:not(.is-disabled) {
@apply bg-gray-100;
}
/* Tamanhos */
.size-sm {
@apply px-2 py-1 text-xs;
}
.size-sm .button-icon {
@apply w-3 h-3;
}
.size-md {
@apply px-3 py-2 text-sm;
}
.size-md .button-icon {
@apply w-4 h-4;
}
.size-lg {
@apply px-4 py-3 text-base;
}
.size-lg .button-icon {
@apply w-5 h-5;
}
.size-xl {
@apply p-4 text-base;
}
.size-xl .button-icon {
@apply w-5 h-5;
}
.button-icon {
@apply flex-shrink-0;
}
.button-text {
@apply font-semibold;
}
/* Loader animation */
.loader {
@apply w-5 h-5 border-2 border-gray-900 border-t-transparent rounded-full animate-spin;
}
</style>

View File

@ -0,0 +1,248 @@
<script setup lang="ts" generic="T">
import { ref, computed } from "vue";
import { onClickOutside } from "@vueuse/core";
import ChevronDown from "@/assets/chevronDown.svg";
export interface DropdownItem<T = any> {
value: T;
label: string;
icon?: string;
disabled?: boolean;
}
const props = withDefaults(
defineProps<{
items: DropdownItem<T>[];
modelValue: T;
placeholder?: string;
searchable?: boolean;
disabled?: boolean;
size?: "sm" | "md" | "lg";
showIcon?: boolean;
}>(),
{
placeholder: "Selecione...",
searchable: false,
disabled: false,
size: "md",
showIcon: true,
}
);
const emit = defineEmits<{
"update:modelValue": [value: T];
change: [value: T];
}>();
const isOpen = ref(false);
const searchQuery = ref("");
const dropdownRef = ref<HTMLElement | null>(null);
const selectedItem = computed(() => {
return props.items.find((item) => item.value === props.modelValue);
});
const filteredItems = computed(() => {
if (!props.searchable || !searchQuery.value) {
return props.items;
}
const query = searchQuery.value.toLowerCase();
return props.items.filter((item) =>
item.label.toLowerCase().includes(query)
);
});
const toggleDropdown = () => {
if (!props.disabled) {
isOpen.value = !isOpen.value;
if (!isOpen.value) {
searchQuery.value = "";
}
}
};
const selectItem = (item: DropdownItem<T>) => {
if (!item.disabled) {
emit("update:modelValue", item.value);
emit("change", item.value);
isOpen.value = false;
searchQuery.value = "";
}
};
onClickOutside(dropdownRef, () => {
isOpen.value = false;
searchQuery.value = "";
});
</script>
<template>
<div ref="dropdownRef" class="dropdown-container">
<button
type="button"
:class="[
'dropdown-trigger',
`size-${size}`,
{ disabled: disabled, open: isOpen },
]"
@click="toggleDropdown"
>
<img
v-if="selectedItem?.icon && showIcon"
:src="selectedItem.icon"
:alt="selectedItem.label"
class="item-icon"
/>
<span class="selected-text">
{{ selectedItem?.label || placeholder }}
</span>
<ChevronDown
class="chevron"
:class="{ rotated: isOpen }"
/>
</button>
<transition name="dropdown-fade">
<div v-if="isOpen" class="dropdown-menu">
<input
v-if="searchable"
v-model="searchQuery"
type="text"
class="search-input"
placeholder="Buscar..."
@click.stop
/>
<div class="items-container">
<div
v-for="item in filteredItems"
:key="String(item.value)"
:class="[
'dropdown-item',
{
selected: item.value === modelValue,
disabled: item.disabled,
},
]"
@click="selectItem(item)"
>
<img
v-if="item.icon && showIcon"
:src="item.icon"
:alt="item.label"
class="item-icon"
/>
<span class="item-label">{{ item.label }}</span>
</div>
<div v-if="filteredItems.length === 0" class="no-results">
Nenhum resultado encontrado
</div>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
.dropdown-container {
@apply relative inline-block;
}
.dropdown-trigger {
@apply flex items-center gap-2 bg-gray-300 hover:bg-gray-200 rounded-3xl transition-colors cursor-pointer border-none outline-none;
}
.dropdown-trigger:focus {
@apply outline-2 outline-indigo-800;
}
.dropdown-trigger.disabled {
@apply opacity-50 cursor-not-allowed;
}
.dropdown-trigger.disabled:hover {
@apply bg-gray-300;
}
.size-sm {
@apply px-2 py-1 text-sm;
}
.size-md {
@apply px-3 py-2 text-base;
}
.size-lg {
@apply px-4 py-3 text-lg;
}
.item-icon {
@apply sm:w-fit w-4 flex-shrink-0;
}
.selected-text {
@apply text-gray-900 font-medium min-w-fit;
}
.chevron {
@apply transition-transform duration-300 invert pr-1;
}
.chevron.rotated {
@apply rotate-180;
}
.dropdown-menu {
@apply absolute right-0 mt-2 bg-white rounded-xl border border-gray-300 shadow-md z-50 min-w-max w-full;
}
.search-input {
@apply w-full px-4 py-3 border-b border-gray-200 outline-none text-gray-900;
}
.search-input:focus {
@apply border-indigo-800;
}
.items-container {
@apply max-h-64 overflow-y-auto;
}
.dropdown-item {
@apply flex items-center gap-2 px-4 py-4 cursor-pointer hover:bg-gray-300 transition-colors text-gray-900 font-semibold text-sm;
}
.dropdown-item.selected {
@apply bg-gray-100;
}
.dropdown-item.disabled {
@apply opacity-50 cursor-not-allowed;
}
.dropdown-item.disabled:hover {
@apply bg-transparent;
}
.item-label {
@apply text-end;
}
.no-results {
@apply px-4 py-6 text-center text-gray-500 text-sm;
}
/* Animação */
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
@apply transition-all duration-200;
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
@apply opacity-0 -translate-y-2;
}
</style>

View File

@ -0,0 +1,55 @@
<script setup lang="ts">
export type ErrorType = "error" | "warning" | "info";
const props = withDefaults(
defineProps<{
message: string;
type?: ErrorType;
centered?: boolean;
icon?: boolean;
}>(),
{
type: "error",
centered: true,
icon: false,
}
);
const colorClasses = {
error: "text-red-500",
warning: "text-amber-500",
info: "text-blue-500",
};
</script>
<template>
<div :class="['error-message-container', { centered: centered }]">
<div :class="['error-message', colorClasses[type]]">
<span v-if="icon" class="icon"></span>
<span class="message">{{ message }}</span>
</div>
</div>
</template>
<style scoped>
.error-message-container {
@apply flex w-full;
}
.error-message-container.centered {
@apply justify-center;
}
.error-message {
@apply font-normal text-sm flex items-center gap-2;
}
.icon {
@apply text-base;
}
.message {
@apply leading-tight;
}
</style>

View File

@ -0,0 +1,55 @@
<script setup lang="ts">
export type FormCardPadding = "sm" | "md" | "lg";
const props = withDefaults(
defineProps<{
padding?: FormCardPadding;
fullWidth?: boolean;
noBorder?: boolean;
}>(),
{
padding: "md",
fullWidth: true,
noBorder: false,
}
);
</script>
<template>
<div
:class="[
'form-card',
`padding-${padding}`,
{ 'full-width': fullWidth, 'no-border': noBorder },
]"
>
<slot></slot>
</div>
</template>
<style scoped>
.form-card {
@apply flex flex-col bg-white rounded-lg;
}
.form-card:not(.no-border) {
@apply border-y-10;
}
.form-card.full-width {
@apply w-full;
}
.padding-sm {
@apply px-4 py-3;
}
.padding-md {
@apply sm:px-10 px-6 py-5;
}
.padding-lg {
@apply px-12 py-8;
}
</style>

View File

@ -0,0 +1,147 @@
<script setup lang="ts">
export type IconButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type IconButtonSize = "sm" | "md" | "lg";
export type IconPosition = "left" | "right";
const props = withDefaults(
defineProps<{
text: string;
icon?: string;
variant?: IconButtonVariant;
size?: IconButtonSize;
iconPosition?: IconPosition;
disabled?: boolean;
fullWidth?: boolean;
}>(),
{
variant: "outline",
size: "md",
iconPosition: "left",
disabled: false,
fullWidth: false,
}
);
const emit = defineEmits<{
click: [];
}>();
const handleClick = () => {
if (!props.disabled) {
emit("click");
}
};
</script>
<template>
<button
type="button"
:class="[
'icon-button',
`variant-${variant}`,
`size-${size}`,
{ 'is-disabled': disabled, 'full-width': fullWidth },
]"
:disabled="disabled"
@click="handleClick"
>
<img
v-if="icon && iconPosition === 'left'"
:src="icon"
:alt="`${text} icon`"
class="button-icon"
/>
<span class="button-text">{{ text }}</span>
<img
v-if="icon && iconPosition === 'right'"
:src="icon"
:alt="`${text} icon`"
class="button-icon"
/>
</button>
</template>
<style scoped>
.icon-button {
@apply flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 cursor-pointer;
}
.icon-button:hover:not(.is-disabled) {
@apply transform scale-[1.02];
}
.icon-button.is-disabled {
@apply opacity-60 cursor-not-allowed;
}
.icon-button.full-width {
@apply w-full;
}
/* Variantes */
.variant-primary {
@apply bg-amber-400 text-gray-900 border-2 border-amber-400;
}
.variant-primary:hover:not(.is-disabled) {
@apply bg-amber-500 border-amber-500;
}
.variant-secondary {
@apply bg-gray-200 text-gray-900 border-2 border-gray-300;
}
.variant-secondary:hover:not(.is-disabled) {
@apply bg-gray-300 border-gray-400;
}
.variant-outline {
@apply bg-transparent text-gray-900 border-2 border-amber-300;
}
.variant-outline:hover:not(.is-disabled) {
@apply bg-amber-300/10;
}
.variant-ghost {
@apply bg-transparent text-gray-900 border-2 border-transparent;
}
.variant-ghost:hover:not(.is-disabled) {
@apply bg-gray-100;
}
/* Tamanhos */
.size-sm {
@apply px-2 py-1 text-xs;
}
.size-sm .button-icon {
@apply w-3 h-3;
}
.size-md {
@apply px-3 py-2 text-sm;
}
.size-md .button-icon {
@apply w-4 h-4;
}
.size-lg {
@apply px-4 py-3 text-base;
}
.size-lg .button-icon {
@apply w-5 h-5;
}
.button-text {
@apply font-semibold;
}
.button-icon {
@apply flex-shrink-0;
}
</style>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useFloating, arrow, offset, flip, shift } from "@floating-ui/vue";
const props = withDefaults(
defineProps<{
text: string;
placement?: "top" | "bottom" | "left" | "right";
iconSrc?: string;
showOnHover?: boolean;
}>(),
{
placement: "right",
iconSrc: "",
showOnHover: true,
}
);
const showTooltip = ref<boolean>(false);
const reference = ref<HTMLElement | null>(null);
const floating = ref<HTMLElement | null>(null);
const floatingArrow = ref(null);
onMounted(() => {
useFloating(reference, floating, {
placement: props.placement,
middleware: [offset(10), flip(), shift(), arrow({ element: floatingArrow })],
});
});
const handleMouseOver = () => {
if (props.showOnHover) {
showTooltip.value = true;
}
};
const handleMouseOut = () => {
if (props.showOnHover) {
showTooltip.value = false;
}
};
const toggleTooltip = () => {
if (!props.showOnHover) {
showTooltip.value = !showTooltip.value;
}
};
</script>
<template>
<div class="info-tooltip-container">
<img
:src="iconSrc || '/src/assets/info.svg'"
alt="info icon"
class="info-icon"
ref="reference"
@mouseover="handleMouseOver"
@mouseout="handleMouseOut"
@click="toggleTooltip"
/>
<div
v-if="showTooltip"
role="tooltip"
ref="floating"
class="tooltip-content"
>
{{ text }}
</div>
</div>
</template>
<style scoped>
.info-tooltip-container {
@apply relative inline-block;
}
.info-icon {
@apply cursor-pointer transition-opacity hover:opacity-70;
}
.tooltip-content {
@apply bg-white text-gray-900 font-medium text-xs md:text-sm px-3 py-2 rounded border-2 border-emerald-500 z-50 max-w-xs shadow-lg;
}
@media screen and (max-width: 640px) {
.tooltip-content {
display: none;
}
}
</style>

View File

@ -0,0 +1,60 @@
<script setup lang="ts">
import SpinnerComponent from "./SpinnerComponent.vue";
const props = withDefaults(
defineProps<{
message?: string;
size?: "sm" | "md" | "lg";
centered?: boolean;
inline?: boolean;
}>(),
{
message: "Carregando...",
size: "md",
centered: true,
inline: false,
}
);
const sizeMap = {
sm: { spinner: "4", text: "text-sm" },
md: { spinner: "6", text: "text-base" },
lg: { spinner: "8", text: "text-lg" },
};
</script>
<template>
<div
:class="[
'loading-state',
{ centered: centered, inline: inline },
]"
>
<span v-if="message" :class="['loading-message', sizeMap[size].text]">
{{ message }}
</span>
<SpinnerComponent
:width="sizeMap[size].spinner"
:height="sizeMap[size].spinner"
/>
</div>
</template>
<style scoped>
.loading-state {
@apply flex items-center gap-2;
}
.loading-state.centered {
@apply justify-center;
}
.loading-state.inline {
@apply inline-flex;
}
.loading-message {
@apply text-gray-900 font-normal;
}
</style>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from "vue";
import { NetworkEnum } from "@/model/NetworkEnum";
import { getNetworkImage } from "@/utils/imagesPath";
const props = withDefaults(
defineProps<{
networks: NetworkEnum[];
size?: "sm" | "md" | "lg";
showLabel?: boolean;
}>(),
{
size: "md",
showLabel: false,
}
);
const sizeMap = {
sm: 16,
md: 24,
lg: 32,
};
const networkData = computed(() => {
return props.networks.map((network) => ({
network,
image: getNetworkImage(String(network)),
name: String(network),
}));
});
</script>
<template>
<div class="network-badges">
<div
v-for="data in networkData"
:key="data.network"
class="network-badge"
:title="data.name"
>
<img
:alt="`${data.name} logo`"
:src="data.image"
:width="sizeMap[size]"
:height="sizeMap[size]"
class="network-icon"
/>
<span v-if="showLabel" class="network-label">
{{ data.name }}
</span>
</div>
</div>
</template>
<style scoped>
.network-badges {
@apply flex gap-2 items-center;
}
.network-badge {
@apply flex items-center gap-1;
}
.network-icon {
@apply flex-shrink-0;
}
.network-label {
@apply text-sm font-medium text-gray-900;
}
</style>

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from "vue";
import { NetworkEnum } from "@/model/NetworkEnum";
import { Networks } from "@/model/Networks";
import { getNetworkImage } from "@/utils/imagesPath";
import Dropdown, { type DropdownItem } from "./Dropdown.vue";
const props = withDefaults(
defineProps<{
modelValue: NetworkEnum;
disabled?: boolean;
size?: "sm" | "md" | "lg";
availableNetworks?: NetworkEnum[];
}>(),
{
disabled: false,
size: "md",
}
);
const emit = defineEmits<{
"update:modelValue": [value: NetworkEnum];
change: [value: NetworkEnum];
}>();
const networkItems = computed((): DropdownItem<NetworkEnum>[] => {
const networks = props.availableNetworks || (Object.values(NetworkEnum).filter(v => typeof v === 'number') as NetworkEnum[]);
return networks.map((network) => ({
value: network,
label: Networks[network]?.chainName || String(network),
icon: getNetworkImage(String(network)),
}));
});
const handleChange = (value: NetworkEnum) => {
emit("update:modelValue", value);
emit("change", value);
};
</script>
<template>
<Dropdown
:model-value="modelValue"
:items="networkItems"
:disabled="disabled"
:size="size"
:show-icon="true"
@update:model-value="handleChange"
/>
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
export type HeaderSize = "sm" | "md" | "lg";
const props = withDefaults(
defineProps<{
title: string;
subtitle?: string;
size?: HeaderSize;
centered?: boolean;
}>(),
{
size: "lg",
centered: true,
}
);
</script>
<template>
<div
:class="['page-header', `size-${size}`, { centered: centered }]"
>
<h1 class="title text-white font-extrabold">
{{ title }}
</h1>
<p v-if="subtitle" class="subtitle text-white font-medium">
{{ subtitle }}
</p>
<slot></slot>
</div>
</template>
<style scoped>
.page-header {
@apply flex flex-col gap-4;
}
.page-header.centered {
@apply items-center justify-center text-center;
}
/* Tamanhos */
.size-sm .title {
@apply sm:text-2xl text-xl sm:max-w-[20rem] max-w-[16rem];
}
.size-sm .subtitle {
@apply sm:text-sm text-xs sm:max-w-[18rem] max-w-[14rem];
}
.size-md .title {
@apply sm:text-4xl text-2xl sm:max-w-[28rem] max-w-[22rem];
}
.size-md .subtitle {
@apply sm:text-base text-sm sm:max-w-[26rem] max-w-[20rem];
}
.size-lg .title {
@apply sm:text-5xl text-3xl sm:max-w-[29rem] max-w-[20rem];
}
.size-lg .subtitle {
@apply sm:text-base text-sm sm:max-w-[28rem] max-w-[30rem] sm:tracking-normal tracking-wide;
}
</style>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from "vue";
export type StatusType = "open" | "expired" | "completed" | "pending";
const props = defineProps<{
status: StatusType;
customText?: string;
}>();
const statusConfig = computed(() => {
const configs: Record<StatusType, { text: string; color: string }> = {
open: {
text: "Em Aberto",
color: "bg-amber-300",
},
expired: {
text: "Expirado",
color: "bg-[#94A3B8]",
},
completed: {
text: "Finalizado",
color: "bg-emerald-300",
},
pending: {
text: "Pendente",
color: "bg-gray-300",
},
};
return configs[props.status];
});
const displayText = computed(() => {
return props.customText || statusConfig.value.text;
});
</script>
<template>
<div :class="[statusConfig.color, 'status-badge']">
{{ displayText }}
</div>
</template>
<style scoped>
.status-badge {
@apply text-xs sm:text-base font-medium text-gray-900 rounded-lg text-center px-2 py-1;
}
</style>

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from "vue";
import { TokenEnum } from "@/model/NetworkEnum";
import { getTokenImage } from "@/utils/imagesPath";
import Dropdown, { type DropdownItem } from "./Dropdown.vue";
const props = withDefaults(
defineProps<{
modelValue: TokenEnum;
disabled?: boolean;
size?: "sm" | "md" | "lg";
}>(),
{
disabled: false,
size: "md",
}
);
const emit = defineEmits<{
"update:modelValue": [value: TokenEnum];
change: [value: TokenEnum];
}>();
const tokenItems = computed((): DropdownItem<TokenEnum>[] => {
return Object.values(TokenEnum).map((token) => ({
value: token,
label: token,
icon: getTokenImage(token),
}));
});
const handleChange = (value: TokenEnum) => {
emit("update:modelValue", value);
emit("change", value);
};
</script>
<template>
<Dropdown
:model-value="modelValue"
:items="tokenItems"
:disabled="disabled"
:size="size"
:show-icon="true"
@update:model-value="handleChange"
/>
</template>

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { onClickOutside } from "@vueuse/core";
import CustomButton from "./CustomButton.vue";
const props = withDefaults(
defineProps<{
walletAddress: string | null;
variant?: "primary" | "secondary" | "outline";
showMenu?: boolean;
}>(),
{
variant: "primary",
showMenu: true,
}
);
const emit = defineEmits<{
connect: [];
disconnect: [];
viewTransactions: [];
}>();
const menuOpen = ref(false);
const menuRef = ref<HTMLElement | null>(null);
const isConnected = computed(() => {
return !!props.walletAddress;
});
const formattedAddress = computed(() => {
if (!props.walletAddress) return "";
const address = props.walletAddress;
const length = address.length;
const start = address.substring(0, 5);
const end = address.substring(length - 4, length);
return `${start}...${end}`;
});
const handleConnect = () => {
emit("connect");
};
const handleDisconnect = () => {
menuOpen.value = false;
emit("disconnect");
};
const handleViewTransactions = () => {
menuOpen.value = false;
emit("viewTransactions");
};
const toggleMenu = () => {
if (isConnected.value && props.showMenu) {
menuOpen.value = !menuOpen.value;
}
};
onClickOutside(menuRef, () => {
menuOpen.value = false;
});
</script>
<template>
<div class="wallet-connect-container">
<CustomButton
v-if="!isConnected"
text="Conectar carteira"
:variant="variant"
@button-clicked="handleConnect"
/>
<div v-else ref="menuRef" class="wallet-connected">
<button
type="button"
class="wallet-button"
@click="toggleMenu"
>
<span class="wallet-address">{{ formattedAddress }}</span>
<div class="wallet-indicator"></div>
</button>
<transition name="menu-fade">
<div v-if="menuOpen && showMenu" class="wallet-menu">
<button
type="button"
class="menu-item"
@click="handleViewTransactions"
>
<span>Ver transações</span>
</button>
<button
type="button"
class="menu-item disconnect"
@click="handleDisconnect"
>
<span>Desconectar</span>
</button>
</div>
</transition>
</div>
</div>
</template>
<style scoped>
.wallet-connect-container {
@apply relative inline-block;
}
.wallet-connected {
@apply relative;
}
.wallet-button {
@apply flex items-center gap-3 px-4 py-2 bg-white border-2 border-amber-400 rounded-lg hover:bg-amber-50 transition-colors cursor-pointer;
}
.wallet-address {
@apply text-gray-900 font-semibold text-sm;
}
.wallet-indicator {
@apply w-2 h-2 bg-emerald-500 rounded-full;
}
.wallet-menu {
@apply absolute top-full right-0 mt-2 bg-white rounded-lg border border-gray-300 shadow-lg z-50 min-w-[200px] overflow-hidden;
}
.menu-item {
@apply w-full px-4 py-3 text-left text-gray-900 font-medium text-sm hover:bg-gray-100 transition-colors cursor-pointer border-none;
}
.menu-item.disconnect {
@apply text-red-500 hover:bg-red-50;
}
/* Animação */
.menu-fade-enter-active,
.menu-fade-leave-active {
@apply transition-all duration-200;
}
.menu-fade-enter-from,
.menu-fade-leave-to {
@apply opacity-0 -translate-y-2;
}
</style>