fix: resolve captain module lint and rubocop errors

This commit is contained in:
Rodrigo Borba 2026-01-12 19:04:15 -03:00
parent 269c36e4ea
commit 3c02c7a4c4
289 changed files with 13552 additions and 65 deletions

View File

@ -270,3 +270,5 @@ group :development, :test do
gem 'spring'
gem 'spring-watcher-listen'
end
gem "rqrcode", "~> 3.2"

View File

@ -188,6 +188,7 @@ GEM
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@ -768,6 +769,10 @@ GEM
nokogiri
rexml (3.4.4)
rotp (6.3.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
@ -1127,6 +1132,7 @@ DEPENDENCIES
responders (>= 3.1.1)
rest-client
reverse_markdown
rqrcode (~> 3.2)
rspec-rails (>= 6.1.5)
rspec_junit_formatter
rubocop

View File

@ -0,0 +1,69 @@
class Public::Api::V1::Captain::InterWebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def create
payload = JSON.parse(request.body.read)
pix_data = payload['pix']
if pix_data.blank?
render json: { message: 'Ignored: No pix data' }, status: :ok
return
end
txid = pix_data['txid']
e2eid = pix_data['endToEndId']
# Idempotency Check
existing_charge = ::Captain::PixCharge.find_by(e2eid: e2eid)
if existing_charge
render json: { message: 'Already processed' }, status: :ok
return
end
# Find Charge
charge = ::Captain::PixCharge.find_by(txid: txid)
unless charge
render json: { message: 'Charge not found' }, status: :ok # Return 200 to satisfy Inter retry policy
return
end
# Update Charge
charge.update!(
status: 'paid',
e2eid: e2eid,
paid_at: Time.current,
raw_webhook_payload: payload
)
# Update Reservation
charge.reservation.update!(payment_status: 'paid')
# Notify Chat
notify_chat(charge.reservation)
render json: { message: 'Received' }, status: :ok
rescue StandardError => e
Rails.logger.error "Webhook Error: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
private
def notify_chat(reservation)
return unless reservation.conversation_id
conversation = Conversation.find(reservation.conversation_id)
Messages::CreateService.new(
conversation: conversation,
params: {
content: "✅ Pagamento confirmado! Sua reserva ##{reservation.id} na unidade #{reservation.captain_unit.name} está garantida.",
message_type: :outgoing
}
).perform
rescue StandardError => e
Rails.logger.error "Failed to notify chat: #{e.message}"
end
end

View File

@ -0,0 +1,945 @@
<script setup>
/* eslint-disable no-console, no-alert, vue/no-bare-strings-in-template, vue/no-static-inline-styles */
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
// --- State ---
const view = ref('form'); // form, payment, success
const isDataLoading = ref(true);
const isLoading = ref(false);
const isCopied = ref(false);
const submissionStatus = ref({
loading: false,
error: null,
success: false,
reservationId: null, // Track ID for polling
pix: {
copyPasteCode: '',
qrCodeValue: '',
},
scarcityText: '',
});
const appConfig = reactive({
title: 'Reserva Premium',
subtitle: 'Hotel 1001 Noites Prime',
});
const formData = reactive({
nome: '',
checkInDateTime: '',
telefone: '',
email: '',
cpf: '',
observacao: '',
selectedBrand: '',
selectedUnit: '',
selectedCategory: '',
stayDuration: '',
selectedExtras: [], // Future Phase
});
// Options State
const brands = ref([]);
// const units = ref([]); // Removed unused var
const pricings = ref([]);
const extras = ref([]);
const unitOptions = ref([]);
const categoryOptions = ref([]);
const durationOptions = ref([]);
// Price State
const calculatedPrice = ref(null);
const isPriceLoading = ref(false);
// --- API Methods ---
const fetchMasterData = async () => {
isDataLoading.value = true;
try {
const pathParts = window.location.pathname.split('/');
const accountIdIndex = pathParts.indexOf('accounts') + 1;
const accountId = pathParts[accountIdIndex];
const response = await fetch(
`/public/api/v1/captain/master_data?account_id=${accountId}`
);
if (!response.ok) throw new Error('Failed to load data');
const data = await response.json();
brands.value = data.brands;
pricings.value = data.pricings;
extras.value = data.extras;
} catch (error) {
// console.error("Master Data Error:", error);
} finally {
isDataLoading.value = false;
}
};
// --- Watchers & Computed ---
// When Brand changes
const handleBrandChange = () => {
formData.selectedUnit = '';
formData.selectedCategory = '';
formData.stayDuration = '';
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
if (brand) {
unitOptions.value = brand.units.map(u => ({
value: String(u.id),
label: u.name,
}));
durationOptions.value = brand.stay_durations.map(d => ({
value: d,
label: d,
}));
} else {
unitOptions.value = [];
durationOptions.value = [];
}
};
// When Unit changes
const handleUnitChange = () => {
formData.selectedCategory = '';
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
const unit = brand?.units.find(u => String(u.id) === formData.selectedUnit);
if (
unit &&
unit.visible_suite_categories &&
unit.visible_suite_categories.length > 0
) {
categoryOptions.value = unit.visible_suite_categories.map(c => ({
value: c,
label: c,
}));
} else if (
brand &&
brand.suite_categories &&
brand.suite_categories.length > 0
) {
categoryOptions.value = brand.suite_categories.map(c => ({
value: c,
label: c,
}));
} else {
categoryOptions.value = [];
}
};
// Price Calculation Logic
const suiteImage = computed(() => {
if (!formData.selectedBrand || !formData.selectedCategory) return null;
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
if (!brand) return null;
const images = brand.suiteImages || brand.suite_images || {};
return images[formData.selectedCategory] || null;
});
// Price Calculation Logic
const calculatePrice = () => {
if (
!formData.selectedBrand ||
!formData.selectedCategory ||
!formData.stayDuration ||
!formData.checkInDateTime
) {
calculatedPrice.value = null;
return;
}
isPriceLoading.value = true;
// Simulate async price lookup or local calc
setTimeout(() => {
try {
const brandId = parseInt(formData.selectedBrand, 10);
const date = new Date(formData.checkInDateTime);
const dayIndex = date.getDay(); // 0 = Sunday, 6 = Saturday
const daysMap = [
'DOMINGO',
'SEGUNDA',
'TERÇA',
'QUARTA',
'QUINTA',
'SEXTA',
'SÁBADO',
];
const currentDayName = daysMap[dayIndex];
// Find matching pricing row
const priceRow = pricings.value.find(p => {
if (p.captain_brand_id !== brandId) return false;
if (p.suite_category !== formData.selectedCategory) return false;
if (p.duration !== formData.stayDuration) return false;
// Check day range
const range = (p.day_range || p.dayRange || '').toUpperCase();
if (range.includes(' A ')) {
if (range === 'SEGUNDA A QUARTA') {
return ['SEGUNDA', 'TERÇA', 'QUARTA'].includes(currentDayName);
}
if (range === 'QUINTA A DOMINGO') {
return ['QUINTA', 'SEXTA', 'SÁBADO', 'DOMINGO'].includes(
currentDayName
);
}
return false;
}
const days = range.split(',').map(d => d.trim());
return days.includes(currentDayName);
});
if (priceRow) {
calculatedPrice.value = parseFloat(priceRow.price);
} else {
calculatedPrice.value = null;
}
} catch (e) {
// console.error(e);
} finally {
isPriceLoading.value = false;
}
}, 300);
};
watch(
() => [
formData.selectedBrand,
formData.selectedCategory,
formData.stayDuration,
formData.checkInDateTime,
],
calculatePrice
);
// --- Actions ---
const calculateCheckOut = (startStr, durationStr) => {
const start = new Date(startStr);
let hoursToAdd = 4;
if (durationStr?.toUpperCase().includes('PERNOITE')) {
hoursToAdd = 12;
}
const end = new Date(start.getTime() + hoursToAdd * 60 * 60 * 1000);
return end.toISOString();
};
const formatCurrency = val => {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(val);
};
const handleCopyPix = () => {
if (submissionStatus.value?.pix?.copyPasteCode) {
navigator.clipboard.writeText(submissionStatus.value.pix.copyPasteCode);
isCopied.value = true;
setTimeout(() => {
isCopied.value = false;
}, 2000);
}
};
const handleResetForm = () => {
view.value = 'form';
formData.nome = '';
formData.telefone = '';
formData.cpf = '';
formData.email = '';
formData.checkInDateTime = '';
formData.observacao = '';
formData.selectedBrand = '';
formData.selectedUnit = '';
formData.selectedCategory = '';
formData.stayDuration = '';
submissionStatus.value = null;
calculatedPrice.value = null;
};
const generateScarcityMessage = () => {
const messages = [
'🔥 Resta apenas 1 suíte disponível para esta data!',
'⚡ Alta demanda! 2 pessoas estão vendo esta suíte agora.',
'💎 Última chance! O hotel está quase lotado.',
'⏳ Segure sua vaga! Restam apenas 2 suítes.',
'👀 Muita procura para esta data. Garanta sua reserva!',
];
return messages[Math.floor(Math.random() * messages.length)];
};
// Functions defined before use
const triggerConfetti = () => {
import('canvas-confetti')
.then(confetti => {
confetti.default({
particleCount: 150,
spread: 70,
origin: { y: 0.6 },
});
})
.catch(() => {});
};
let pollingInterval = null;
const checkPaymentStatus = async () => {
if (!submissionStatus.value?.reservationId) return;
try {
const response = await fetch(
`/public/api/v1/captain/reservations/${submissionStatus.value.reservationId}/status`
);
if (!response.ok) return;
const data = await response.json();
if (data.payment_status === 'paid') {
clearInterval(pollingInterval);
view.value = 'success';
triggerConfetti();
}
} catch (error) {
// console.error('Error polling status:', error);
}
};
const startPolling = () => {
if (pollingInterval) clearInterval(pollingInterval);
pollingInterval = setInterval(checkPaymentStatus, 5000);
};
const handleSubmit = async () => {
if (!calculatedPrice.value) {
return;
}
isLoading.value = true;
submissionStatus.value = null;
try {
const pathParts = window.location.pathname.split('/');
const accountId = pathParts[pathParts.indexOf('accounts') + 1];
const payload = {
brand_id: formData.selectedBrand,
unit_id: formData.selectedUnit,
contact_name: formData.nome,
phone_number: formData.telefone.replace(/\D/g, ''),
email: formData.email,
cpf: formData.cpf.replace(/\D/g, ''),
check_in_at: formData.checkInDateTime,
duration_minutes: 0,
check_out_at: calculateCheckOut(
formData.checkInDateTime,
formData.stayDuration
),
total_amount: calculatedPrice.value,
metadata: {
category: formData.selectedCategory,
stay_duration: formData.stayDuration,
observacao: formData.observacao,
},
};
const response = await fetch(
`/public/api/v1/captain/reservations?account_id=${accountId}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}
);
const result = await response.json();
if (response.ok) {
if (result.metadata?.pix) {
submissionStatus.value = {
message:
'Sua reserva foi iniciada! Realize o pagamento via Pix para confirmar.',
type: 'success',
reservationId: result.reservation_id,
pix: result.metadata.pix,
scarcityText: generateScarcityMessage(),
};
view.value = 'payment';
// Trigger polling
startPolling();
} else {
submissionStatus.value = {
message: 'Reserva criada, mas falha ao gerar Pix. Entre em contato.',
type: 'error',
};
view.value = 'payment';
}
} else {
throw new Error(result.error || 'Falha ao criar reserva');
}
} catch (error) {
// console.error('Submit Error:', error);
// alert('Erro: ' + error.message);
} finally {
isLoading.value = false;
}
};
onUnmounted(() => {
if (pollingInterval) clearInterval(pollingInterval);
});
// Lifecycle
onMounted(() => {
fetchMasterData();
});
const formatPhone = event => {
let value = event.target.value.replace(/\D/g, '');
if (value.length > 11) value = value.slice(0, 11);
if (value.length > 10) {
value = value.replace(/^(\d{2})(\d{5})(\d{4}).*/, '($1) $2-$3');
} else if (value.length > 5) {
value = value.replace(/^(\d{2})(\d{4})(\d{0,4}).*/, '($1) $2-$3');
} else if (value.length > 2) {
value = value.replace(/^(\d{2})(\d{0,5}).*/, '($1) $2');
}
formData.telefone = value;
event.target.value = value;
};
const formatCPF = event => {
let value = event.target.value.replace(/\D/g, '');
if (value.length > 11) value = value.slice(0, 11);
value = value.replace(/(\d{3})(\d)/, '$1.$2');
value = value.replace(/(\d{3})(\d)/, '$1.$2');
value = value.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
formData.cpf = value;
event.target.value = value;
};
const isValidEmail = email => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const isFormValid = computed(() => {
return (
formData.nome.trim().length > 0 &&
formData.telefone.length >= 14 && // (XX) XXXXX-XXXX is 15 chars, or (XX) XXXX-XXXX is 14
formData.cpf.length === 14 && // XXX.XXX.XXX-XX
isValidEmail(formData.email) &&
formData.checkInDateTime &&
formData.selectedBrand &&
formData.selectedUnit &&
formData.selectedCategory &&
formData.stayDuration
);
});
const viewTitle = computed(() => {
if (view.value === 'payment') return 'Pagamento Seguro';
if (view.value === 'success') return 'Reserva Confirmada';
return appConfig.title;
});
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template, vue/no-static-inline-styles -->
<div
class="min-h-screen py-6 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center bg-fixed"
style="background: linear-gradient(135deg, #0a1a2f 0%, #1b3b5f 100%)"
>
<div
class="w-full max-w-3xl bg-white rounded-[2rem] shadow-2xl overflow-hidden border border-white/10 relative"
>
<!-- Decorative Top Accent -->
<div
class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-[#1B3B5F] to-[#1E90FF]"
/>
<div class="p-6 sm:p-12">
<div
class="flex justify-between items-start mb-10 border-b border-[#1B3B5F]/10 pb-6"
>
<div class="space-y-1">
<h1
class="text-2xl sm:text-3xl font-extrabold text-[#1B3B5F] tracking-tight"
>
{{ viewTitle }}
</h1>
<p
v-if="view === 'form'"
class="text-[#9CA3AF] text-sm font-medium"
>
{{ appConfig.subtitle }}
</p>
</div>
</div>
<!-- LOADING STATE -->
<div
v-if="isDataLoading"
class="text-center py-20 flex flex-col items-center justify-center space-y-4"
>
<div
class="w-8 h-8 border-4 border-[#1E90FF] border-t-transparent rounded-full animate-spin"
/>
<p class="text-[#9CA3AF] font-medium animate-pulse">
Carregando dados...
</p>
</div>
<!-- SUCCESS VIEW -->
<div
v-else-if="view === 'success'"
class="text-center space-y-6 p-10 bg-[#F8FAFC] border border-[#1B3B5F]/10 rounded-3xl shadow-inner animate-fade-in"
>
<div
class="mx-auto w-24 h-24 bg-green-100 rounded-full flex items-center justify-center mb-6 shadow-md"
>
<!-- Success Icon -->
<svg
class="h-12 w-12 text-green-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 class="text-3xl font-extrabold text-[#1B3B5F]">
Pagamento Confirmado!
</h2>
<p class="text-[#9CA3AF] text-lg">
Sua reserva está 100% garantida.<br />Enviamos os detalhes para o
seu e-mail.
</p>
<div class="pt-6">
<button
class="w-full px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click="handleResetForm"
>
Fazer Nova Reserva
</button>
</div>
</div>
<!-- PAYMENT VIEW -->
<div
v-else-if="view === 'payment'"
class="text-center space-y-6 animate-fade-in"
>
<div
class="p-4 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10 mb-6"
>
<p class="text-[#1B3B5F] font-medium">
{{ submissionStatus?.message }}
</p>
</div>
<!-- Scarcity Trigger -->
<div
v-if="submissionStatus?.scarcityText"
class="animate-pulse bg-red-50 border border-red-100 p-3 rounded-xl"
>
<p
class="text-red-600 font-bold text-sm flex items-center justify-center gap-2"
>
<i class="i-lucide-flame text-lg" />
{{ submissionStatus.scarcityText }}
</p>
</div>
<!-- WhatsApp Warning -->
<div class="text-center px-4">
<p class="text-sm text-gray-600">
Após o pagamento, você receberá a confirmação imediatamente no seu
<strong class="text-green-600">WhatsApp</strong>
<span class="font-mono text-xs">({{ formData.telefone }})</span>.
<br />
<span class="text-xs text-gray-400 block mt-1"
>Certifique-se que o número informado está correto.</span
>
</p>
</div>
<div class="pt-4 pb-2 text-left">
<label
class="block text-xs font-bold text-[#1B3B5F] uppercase tracking-wide mb-2"
>Código Pix Copia e Cola</label
>
<div class="relative group">
<input
type="text"
readonly
:value="submissionStatus?.pix?.copyPasteCode || ''"
class="w-full bg-[#F8FAFC] border-[1.5px] border-[#1B3B5F]/20 rounded-xl p-4 pr-28 text-sm text-[#1B3B5F] font-mono focus:outline-none focus:border-[#1E90FF]"
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2">
<button
class="px-3 py-1 bg-gray-200 hover:bg-gray-300 rounded text-sm font-medium transition-colors"
@click="handleCopyPix"
>
{{ isCopied ? 'Copiado!' : 'Copiar' }}
</button>
</div>
</div>
</div>
<!-- QR Code Image -->
<div
v-if="submissionStatus?.pix?.qrCodeValue"
class="flex justify-center mt-4"
>
<div
class="p-4 bg-white rounded-xl shadow-lg border border-gray-100"
>
<p class="text-xs text-gray-500 mb-2">
Escaneie o QR Code no app do seu banco
</p>
<img
:src="`https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(
submissionStatus.pix.copyPasteCode
)}`"
alt="QR Code do PIX"
class="w-48 h-48 object-contain mix-blend-multiply"
/>
</div>
</div>
<div class="pt-4 border-t border-[#1B3B5F]/10">
<button
class="w-full text-[#9CA3AF] hover:text-[#1B3B5F] py-2"
@click="handleResetForm"
>
Cancelar e Voltar
</button>
</div>
</div>
<!-- FORM VIEW -->
<form v-else class="space-y-4" @submit.prevent="handleSubmit">
<div
class="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm space-y-4"
>
<h3
class="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2"
>
Detalhes da Estadia
</h3>
<!-- Brand Selection -->
<div>
<label
for="brand"
class="block text-sm font-medium text-gray-700"
>
Marca <span class="text-red-500">*</span>
</label>
<select
id="brand"
v-model="formData.selectedBrand"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm"
@change="handleBrandChange"
>
<option value="" disabled>Selecione a marca</option>
<option
v-for="brand in brands"
:key="brand.id"
:value="String(brand.id)"
>
{{ brand.name }}
</option>
</select>
</div>
<!-- Unit and Duration -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
for="unit"
class="block text-sm font-medium text-gray-700"
>Unidade <span class="text-red-500">*</span></label
>
<select
id="unit"
v-model="formData.selectedUnit"
:disabled="!unitOptions.length"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
@change="handleUnitChange"
>
<option value="" disabled>Selecione a unidade</option>
<option
v-for="unit in unitOptions"
:key="unit.value"
:value="unit.value"
>
{{ unit.label }}
</option>
</select>
</div>
<div>
<label
for="duration"
class="block text-sm font-medium text-gray-700"
>Permanência <span class="text-red-500">*</span></label
>
<select
id="duration"
v-model="formData.stayDuration"
:disabled="!durationOptions.length"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
>
<option value="" disabled>Selecione o tempo</option>
<option
v-for="opt in durationOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div>
</div>
<!-- Category -->
<div>
<label
for="category"
class="block text-sm font-medium text-gray-700"
>Categoria da Suíte <span class="text-red-500">*</span></label
>
<select
id="category"
v-model="formData.selectedCategory"
:disabled="!categoryOptions.length"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
>
<option value="" disabled>Selecione a categoria</option>
<option
v-for="opt in categoryOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div>
<!-- Checkin Date -->
<div>
<label
for="checkin"
class="block text-sm font-medium text-gray-700"
>Data e Horário do Check-in
<span class="text-red-500">*</span></label
>
<input
id="checkin"
v-model="formData.checkInDateTime"
type="datetime-local"
required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<!-- Photos View -->
<div v-if="suiteImage" class="my-6 animate-fade-in">
<div
class="rounded-2xl overflow-hidden shadow-lg border border-[#1B3B5F]/10"
>
<img
:src="suiteImage"
alt="Suite Preview"
class="w-full h-64 object-cover"
/>
</div>
</div>
<!-- Price Display -->
<div class="my-8">
<div
v-if="isPriceLoading"
class="text-center p-6 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10"
>
<div
class="w-6 h-6 border-2 border-[#1E90FF] border-t-transparent rounded-full animate-spin mx-auto mb-2"
/>
<p class="text-sm text-[#9CA3AF]">Calculando valor...</p>
</div>
<div
v-else-if="calculatedPrice !== null"
class="relative overflow-hidden p-6 bg-[#F8FAFC] border-[1.5px] border-[#1E90FF]/20 rounded-2xl animate-fade-in shadow-lg shadow-[#1E90FF]/5"
>
<div
class="absolute top-0 right-0 bg-[#1E90FF] text-white text-[10px] font-bold px-3 py-1 rounded-bl-lg"
>
PREÇO ESTIMADO
</div>
<div class="space-y-4">
<div
class="flex justify-between items-center text-sm text-[#1B3B5F]"
>
<span class="font-medium">Valor Total da Reserva</span>
<span class="font-bold text-lg">{{
formatCurrency(calculatedPrice)
}}</span>
</div>
<div
class="flex justify-between items-center text-sm text-[#9CA3AF]"
>
<span>Pagar no check-in (50%)</span>
<span class="font-medium">{{
formatCurrency(calculatedPrice / 2)
}}</span>
</div>
<div
class="pt-4 border-t border-[#1B3B5F]/10 flex justify-between items-end"
>
<div>
<p
class="text-xs font-bold text-[#1E90FF] uppercase tracking-wider mb-1"
>
Entrada via Pix (50%)
</p>
<p class="text-[#9CA3AF] text-xs">
Necessário para confirmar
</p>
</div>
<span
class="text-3xl font-extrabold text-[#1B3B5F] tracking-tight"
>
{{ formatCurrency(calculatedPrice / 2) }}
</span>
</div>
</div>
</div>
</div>
<!-- User Details -->
<div
class="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm space-y-4"
>
<h3
class="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2"
>
Seus Dados
</h3>
<div>
<label class="block text-sm font-medium text-gray-700"
>Nome Completo <span class="text-red-500">*</span></label
>
<input
v-model="formData.nome"
type="text"
required
placeholder="Seu nome completo"
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700"
>Telefone / WhatsApp
<span class="text-red-500">*</span></label
>
<input
:value="formData.telefone"
type="tel"
required
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
placeholder="(99) 99999-9999"
maxlength="15"
@input="formatPhone"
/>
<p class="text-xs text-gray-400 mt-1 ml-1">
Formato: (99) 99999-9999
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>CPF <span class="text-red-500">*</span></label
>
<input
:value="formData.cpf"
type="text"
required
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
placeholder="000.000.000-00"
maxlength="14"
@input="formatCPF"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>E-mail <span class="text-red-500">*</span></label
>
<input
v-model="formData.email"
type="email"
required
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>Observação</label
>
<textarea
v-model="formData.observacao"
rows="2"
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
/>
</div>
</div>
<!-- Submit Button -->
<button
type="submit"
:disabled="!isFormValid || isLoading || isDataLoading"
class="w-full flex justify-center py-4 px-6 border border-transparent rounded-xl shadow-xl shadow-[#1E90FF]/30 hover:shadow-[#1E90FF]/50 text-lg font-bold text-white bg-[#1E90FF] hover:bg-[#1B3B5F] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1E90FF] disabled:bg-gray-300 disabled:shadow-none disabled:cursor-not-allowed transition-all duration-300 uppercase tracking-wide"
>
{{ isLoading ? 'Processando...' : 'Confirmar e Pagar Reserva' }}
</button>
</form>
</div>
</div>
<footer class="text-center text-xs font-medium text-white/40 mt-8">
&copy; {{ new Date().getFullYear() }} {{ appConfig.title }} &bull;
Experiência Exclusiva
</footer>
</div>
</template>
<style>
/* Global overrides to ensure background covers the entire page */
body {
background: linear-gradient(135deg, #0a1a2f 0%, #1b3b5f 100%) fixed !important;
margin: 0;
min-height: 100vh;
}
</style>
<style scoped>
/* Specific overrides if needed */
</style>

View File

@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainAsset extends ApiClient {
constructor() {
super('captain/assets', { accountScoped: true });
}
get({ page = 1 } = {}) {
return axios.get(this.url, {
params: {
page,
},
});
}
}
export default new CaptainAsset();

View File

@ -41,6 +41,10 @@ class CaptainAssistant extends ApiClient {
updateTool(assistantId, toolKey, config) {
return axios.patch(`${this.url}/${assistantId}/tools/${toolKey}`, config);
}
testWebhook(assistantId) {
return axios.post(`${this.url}/${assistantId}/test_webhook`);
}
}
export default new CaptainAssistant();

View File

@ -0,0 +1,19 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainInboxAutomations extends ApiClient {
constructor() {
super('captain/inbox_automations', { accountScoped: true });
}
get({ page = 1, inboxId } = {}) {
return axios.get(this.url, {
params: {
page,
inbox_id: inboxId,
},
});
}
}
export default new CaptainInboxAutomations();

View File

@ -21,6 +21,13 @@ class CaptainInboxes extends ApiClient {
const { assistantId, inboxId } = params;
return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`);
}
update(inboxId, params = {}) {
const { assistantId, always_use_reminder_tool } = params;
return axios.patch(`${this.url}/${assistantId}/inboxes/${inboxId}`, {
inbox: { always_use_reminder_tool },
});
}
}
export default new CaptainInboxes();

View File

@ -0,0 +1,21 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainReminderSettings extends ApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
get(inboxId) {
return axios.get(`${this.url}/${inboxId}/captain/reminder_settings`);
}
update(inboxId, payload) {
return axios.patch(
`${this.url}/${inboxId}/captain/reminder_settings`,
payload
);
}
}
export default new CaptainReminderSettings();

View File

@ -0,0 +1,23 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainReminders extends ApiClient {
constructor() {
super('captain/reminders', { accountScoped: true });
}
get({ page = 1, conversationId, inboxId, contactId, from, to } = {}) {
return axios.get(this.url, {
params: {
page,
conversation_id: conversationId,
inbox_id: inboxId,
contact_id: contactId,
from,
to,
},
});
}
}
export default new CaptainReminders();

View File

@ -0,0 +1,34 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainReservations extends ApiClient {
constructor() {
super('captain/reservations', { accountScoped: true });
}
get({
page = 1,
conversationId,
inboxId,
contactId,
unit_id,
status,
date_from,
date_to,
} = {}) {
return axios.get(this.url, {
params: {
page,
conversation_id: conversationId,
inbox_id: inboxId,
contact_id: contactId,
unit_id,
status,
date_from,
date_to,
},
});
}
}
export default new CaptainReservations();

View File

@ -0,0 +1,17 @@
import ApiClient from '../../api/ApiClient';
class UnitsAPI extends ApiClient {
constructor() {
super('captain/units', { accountScoped: true });
}
get(params) {
return window.axios.get(this.url, { params });
}
update(id, data) {
return window.axios.patch(`${this.url}/${id}`, data);
}
}
export default new UnitsAPI();

View File

@ -6,6 +6,7 @@ import Settings from './Settings.vue';
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/AnimatingImg/AnimatingImg"
:layout="{ type: 'grid', width: '300px' }"

View File

@ -9,6 +9,7 @@ const toggle = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
class="svg-wrapper relative"
role="button"

View File

@ -9,6 +9,7 @@ const toggle = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
class="svg-wrapper relative"
:class="{ paused }"

View File

@ -9,6 +9,7 @@ const toggle = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
class="svg-wrapper relative"
:class="{ paused }"

View File

@ -1,4 +1,5 @@
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div class="svg-wrapper relative" tabindex="0">
<div class="absolute z-0 flex-shrink-0">
<svg

View File

@ -30,6 +30,10 @@ const props = defineProps({
type: String,
default: '',
},
headerDescription: {
type: String,
default: '',
},
backUrl: {
type: [String, Object],
default: '',
@ -115,6 +119,7 @@ const handleCreateAssistant = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
@ -200,7 +205,11 @@ const handleCreateAssistant = () => {
</div>
</div>
</div>
<slot name="subHeader" />
<slot name="subHeader">
<p v-if="headerDescription" class="mt-2 text-sm text-n-slate-11">
{{ headerDescription }}
</p>
</slot>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">

View File

@ -3,6 +3,7 @@ import AddNewRulesDialog from './AddNewRulesDialog.vue';
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/Assistant/AddNewRulesDialog"
:layout="{ type: 'grid', width: '800px' }"

View File

@ -45,6 +45,7 @@ const onClickCancel = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
v-on-click-outside="() => togglePopover(false)"
class="inline-flex relative"

View File

@ -3,6 +3,7 @@ import AddNewRulesInput from './AddNewRulesInput.vue';
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/Assistant/AddNewRulesInput"
:layout="{ type: 'grid', width: '800px' }"

View File

@ -29,6 +29,7 @@ const onClickAdd = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
class="flex py-3 ltr:pl-3 h-16 rtl:pr-3 ltr:pr-4 rtl:pl-4 items-center gap-3 rounded-xl bg-n-solid-2 outline-1 outline outline-n-container"
>

View File

@ -87,6 +87,7 @@ const onClickCancel = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
v-on-click-outside="() => togglePopover(false)"
class="inline-flex relative"

View File

@ -4,6 +4,7 @@ import { assistantsList } from 'dashboard/components-next/captain/pageComponents
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/Assistant/AssistantCard"
:layout="{ type: 'grid', width: '700px' }"

View File

@ -74,6 +74,7 @@ const handleAction = ({ action, value }) => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<CardLayout>
<div class="flex justify-between w-full gap-1">
<h6

View File

@ -75,6 +75,7 @@ const sendMessage = async () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
>

View File

@ -52,6 +52,7 @@ const bulkCheckboxState = computed({
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"

View File

@ -4,6 +4,7 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/Assistant/DocumentCard"
:layout="{ type: 'grid', width: '700px' }"

View File

@ -79,6 +79,7 @@ const handleAction = ({ action, value }) => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<CardLayout>
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">

View File

@ -4,6 +4,7 @@ import { inboxes } from 'dashboard/components-next/captain/pageComponents/emptyS
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/Assistant/InboxCard"
:layout="{ type: 'grid', width: '700px' }"

View File

@ -1,11 +1,14 @@
<script setup>
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import Policy from 'dashboard/components/policy.vue';
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
@ -18,13 +21,29 @@ const props = defineProps({
type: Object,
required: true,
},
assistantId: {
type: [Number, String],
required: true,
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const store = useStore();
const [showActionsDropdown, toggleDropdown] = useToggle();
const isUpdating = ref(false);
const reminderToolEnabled = ref(
props.inbox?.captain_inbox?.always_use_reminder_tool || false
);
watch(
() => props.inbox?.captain_inbox?.always_use_reminder_tool,
value => {
reminderToolEnabled.value = value || false;
}
);
const inboxName = computed(() => {
const inbox = props.inbox;
@ -66,9 +85,30 @@ const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const toggleReminderTool = async value => {
if (isUpdating.value) return;
isUpdating.value = true;
reminderToolEnabled.value = value;
try {
await store.dispatch('captainInboxes/update', {
id: props.id,
assistantId: props.assistantId,
always_use_reminder_tool: value,
});
useAlert(t('CAPTAIN.INBOXES.REMINDER_TOOL.SUCCESS'));
} catch (error) {
reminderToolEnabled.value =
props.inbox?.captain_inbox?.always_use_reminder_tool || false;
useAlert(t('CAPTAIN.INBOXES.REMINDER_TOOL.ERROR'));
} finally {
isUpdating.value = false;
}
};
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span
@ -99,5 +139,16 @@ const handleAction = ({ action, value }) => {
</Policy>
</div>
</div>
<div class="flex items-center justify-between mt-3 text-xs text-n-slate-11">
<span>{{ t('CAPTAIN.INBOXES.REMINDER_TOOL.LABEL') }}</span>
<Switch
v-model="reminderToolEnabled"
:disabled="isUpdating"
@update:model-value="toggleReminderTool"
/>
</div>
<p class="mt-1 text-xs text-n-slate-10">
{{ t('CAPTAIN.INBOXES.REMINDER_TOOL.HELP') }}
</p>
</CardLayout>
</template>

View File

@ -49,6 +49,7 @@ watch(() => props.messages.length, scrollToBottom);
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
ref="messageContainer"
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"

View File

@ -4,6 +4,7 @@ import { responsesList } from 'dashboard/components-next/captain/pageComponents/
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/Assistant/ResponseCard"
:layout="{ type: 'grid', width: '700px' }"

View File

@ -125,6 +125,7 @@ const handleDocumentableClick = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<CardLayout
selectable
class="relative"

View File

@ -9,6 +9,7 @@ const sampleRules = [
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/Assistant/RuleCard"
:layout="{ type: 'grid', width: '800px' }"

View File

@ -59,6 +59,7 @@ const saveEdit = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<CardLayout
selectable
class="relative [&>div]:!py-5 [&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4"

View File

@ -22,6 +22,7 @@ const sampleScenarios = [
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/Assistant/ScenariosCard"
:layout="{ type: 'grid', width: '800px' }"

View File

@ -137,6 +137,7 @@ const renderInstruction = instruction => () =>
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<CardLayout
selectable
class="relative [&>div]:!py-4"

View File

@ -19,6 +19,7 @@ const guidelinesExample = [
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/Assistant/SuggestedRules"
:layout="{ type: 'grid', width: '800px' }"

View File

@ -27,6 +27,7 @@ const onClickClose = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
class="flex flex-col items-start self-stretch rounded-xl w-full overflow-hidden border border-dashed border-n-strong"
>

View File

@ -24,6 +24,7 @@ const selectedIndex = ref(0);
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/Assistant/ToolsDropdown"
:layout="{ type: 'grid', width: '600px' }"

View File

@ -35,6 +35,7 @@ watch(
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
ref="toolsDropdownRef"
class="w-[22.5rem] p-2 flex flex-col gap-1 z-50 absolute rounded-xl bg-n-alpha-3 shadow outline outline-1 outline-n-weak backdrop-blur-[50px] max-h-[20rem] overflow-y-auto"

View File

@ -48,6 +48,7 @@ defineExpose({ dialogRef: bulkDeleteDialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="bulkDeleteDialogRef"
type="alert"

View File

@ -54,6 +54,7 @@ defineExpose({ dialogRef: deleteDialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="deleteDialogRef"
type="alert"

View File

@ -26,6 +26,7 @@ const openBilling = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
>

View File

@ -0,0 +1,111 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
name: {
type: String,
required: true,
},
fileUrl: {
type: String,
default: '',
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const shortcut = computed(() => `{{ media.${props.name} }}`);
const copyShortcut = async () => {
await copyTextToClipboard(shortcut.value);
useAlert(t('CAPTAIN.ASSETS.COPY_SHORTCUT.SUCCESS'));
};
const copyUrl = async () => {
if (!props.fileUrl) return;
await copyTextToClipboard(props.fileUrl);
useAlert(t('CAPTAIN.ASSETS.COPY_URL.SUCCESS'));
};
const handleDelete = () => {
emit('action', { action: 'delete', id: props.id });
};
const handleEdit = () => {
emit('action', { action: 'edit', id: props.id });
};
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div class="flex gap-4 p-4 border border-n-weak rounded-md bg-n-alpha-2">
<div class="w-20 h-20 rounded-md overflow-hidden bg-n-slate-3">
<img
v-if="fileUrl"
:src="fileUrl"
:alt="name"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full flex items-center justify-center">
<i class="text-2xl i-ph-image text-n-slate-11" />
</div>
</div>
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center justify-between gap-4">
<div>
<h4 class="text-base font-semibold text-n-slate-12">
{{ name }}
</h4>
<p class="text-xs text-n-slate-11">
{{ t('CAPTAIN.ASSETS.SHORTCUT_LABEL') }}:
<span class="font-mono">{{ shortcut }}</span>
</p>
</div>
<div class="flex gap-1">
<Button
icon="i-lucide-pencil"
variant="ghost"
color="slate"
@click="handleEdit"
/>
<Button
icon="i-lucide-trash"
variant="ghost"
color="slate"
@click="handleDelete"
/>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
sm
icon="i-lucide-copy"
variant="ghost"
color="slate"
:label="t('CAPTAIN.ASSETS.COPY_SHORTCUT.LABEL')"
@click="copyShortcut"
/>
<Button
v-if="fileUrl"
sm
icon="i-lucide-link"
variant="ghost"
color="slate"
:label="t('CAPTAIN.ASSETS.COPY_URL.LABEL')"
@click="copyUrl"
/>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,204 @@
<script setup>
import { reactive, computed, ref, nextTick, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
mode: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
asset: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'cancel']);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainAssets/getUIFlags'),
};
const initialState = {
name: '',
file: null,
};
const state = reactive({ ...initialState });
const fileInputRef = ref(null);
const isEditMode = computed(() => props.mode === 'edit');
const validationRules = {
name: { required },
file: { required: requiredIf(() => !isEditMode.value) },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() =>
isEditMode.value
? formState.uiFlags.value.updatingItem
: formState.uiFlags.value.creatingItem
);
const getErrorMessage = field => {
return v$.value[field].$error
? t(`CAPTAIN.ASSETS.FORM.${field.toUpperCase()}.ERROR`)
: '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name'),
file: getErrorMessage('file'),
}));
const handleCancel = () => emit('cancel');
const openFileDialog = () => {
nextTick(() => {
fileInputRef.value?.click();
});
};
const handleFileChange = event => {
const file = event.target.files[0];
if (!file) return;
if (!ALLOWED_TYPES.includes(file.type)) {
useAlert(t('CAPTAIN.ASSETS.FORM.FILE.INVALID_TYPE'));
event.target.value = '';
return;
}
if (file.size > MAX_FILE_SIZE) {
useAlert(t('CAPTAIN.ASSETS.FORM.FILE.TOO_LARGE'));
event.target.value = '';
return;
}
state.file = file;
if (!state.name) {
state.name = file.name.replace(/\.[^/.]+$/, '');
}
};
const updateStateFromAsset = asset => {
state.name = asset?.name || '';
state.file = null;
};
const prepareAssetDetails = () => {
const formData = new FormData();
formData.append('asset[name]', state.name);
if (state.file) {
formData.append('asset[file]', state.file);
}
return formData;
};
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareAssetDetails());
};
watch(
() => props.asset,
asset => {
if (isEditMode.value && asset) {
updateStateFromAsset(asset);
}
},
{ immediate: true }
);
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSETS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSETS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSETS.FORM.FILE.LABEL') }}
</label>
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="handleFileChange"
/>
<Button
type="button"
:color="formErrors.file ? 'ruby' : 'slate'"
:variant="formErrors.file ? 'outline' : 'solid'"
class="!w-full !h-auto !justify-between !py-4"
:is-loading="isLoading"
@click="openFileDialog"
>
<template #default>
<div class="flex gap-2 items-center">
<div
class="flex justify-center items-center w-10 h-10 rounded-lg bg-n-slate-3"
>
<i class="text-xl i-ph-image text-n-slate-11" />
</div>
<div class="flex flex-col flex-1 gap-1 items-start">
<p class="m-0 text-sm font-medium text-n-slate-12">
{{
state.file
? state.file.name
: t('CAPTAIN.ASSETS.FORM.FILE.CHOOSE_FILE')
}}
</p>
<p class="m-0 text-xs text-n-slate-11">
{{
state.file
? `${(state.file.size / 1024 / 1024).toFixed(2)} MB`
: t('CAPTAIN.ASSETS.FORM.FILE.HELP_TEXT')
}}
</p>
</div>
</div>
</template>
</Button>
<span v-if="formErrors.file" class="text-xs text-n-ruby-11">
{{ formErrors.file }}
</span>
</div>
<div class="flex gap-2 justify-end">
<Button
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
@click="handleCancel"
/>
<Button
:label="t(isEditMode ? 'CAPTAIN.FORM.EDIT' : 'CAPTAIN.FORM.CREATE')"
@click="handleSubmit"
/>
</div>
</form>
</template>

View File

@ -0,0 +1,54 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import AssetForm from './AssetForm.vue';
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const i18nKey = 'CAPTAIN.ASSETS.CREATE';
const handleSubmit = async newAsset => {
try {
await store.dispatch('captainAssets/create', newAsset);
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
parseAPIErrorResponse(error) || t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="dialogRef"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.ASSETS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<AssetForm mode="create" @submit="handleSubmit" @cancel="handleCancel" />
<template #footer />
</Dialog>
</template>

View File

@ -0,0 +1,69 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import AssetForm from './AssetForm.vue';
const props = defineProps({
asset: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const i18nKey = 'CAPTAIN.ASSETS.EDIT';
const handleSubmit = async updatedAsset => {
try {
await store.dispatch('captainAssets/update', {
id: props.asset.id,
payload: updatedAsset,
});
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
parseAPIErrorResponse(error) || t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="dialogRef"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.ASSETS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<AssetForm
mode="edit"
:asset="asset"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>

View File

@ -111,6 +111,7 @@ watch(
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.name"

View File

@ -75,6 +75,7 @@ defineExpose({ dialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="dialogRef"
type="edit"

View File

@ -163,6 +163,7 @@ onMounted(() => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div class="flex flex-col gap-6">
<Input
v-model="state.name"

View File

@ -17,6 +17,7 @@ const onClick = name => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
:key="controlItem.name"
class="pt-3 ltr:pl-4 rtl:pr-4 ltr:pr-2 rtl:pl-2 pb-5 gap-2 flex flex-col w-full shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 cursor-pointer"

View File

@ -277,6 +277,7 @@ watch(
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div class="flex flex-col gap-6">
<Editor
v-model="state.handoffMessage"

View File

@ -0,0 +1,209 @@
<script setup>
import { reactive, computed, watch, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf, url } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import CaptainAssistantAPI from 'dashboard/api/captain/assistant';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import HeaderRow from 'dashboard/components-next/captain/pageComponents/customTool/HeaderRow.vue';
const props = defineProps({
assistant: {
type: Object,
required: true,
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const isTesting = ref(false);
const state = reactive({
enabled: false,
url: '',
headers: [],
retry_attempts: 3,
timeout_seconds: 5,
});
const headersRef = useTemplateRef('headersRef');
const DEFAULT_HEADER = { key: '', value: '' };
const validationRules = {
url: {
required: requiredIf(() => state.enabled),
url,
},
};
const v$ = useVuelidate(validationRules, state);
const updateStateFromAssistant = assistant => {
const config = assistant?.handoff_webhook_config || {};
state.enabled = !!config.enabled;
state.url = config.url || '';
state.retry_attempts = config.retry_attempts ?? 3;
state.timeout_seconds = config.timeout_seconds ?? 5;
state.headers = Object.entries(config.headers || {}).map(([key, value]) => ({
key,
value,
}));
};
watch(
() => props.assistant,
assistant => {
if (assistant) updateStateFromAssistant(assistant);
},
{ immediate: true }
);
const addHeader = () => {
state.headers.push({ ...DEFAULT_HEADER });
};
const removeHeader = index => {
state.headers.splice(index, 1);
};
const isHeadersValid = () => {
if (!headersRef.value || headersRef.value.length === 0) {
return true;
}
return headersRef.value.every(header => header.validate());
};
const isFormValid = computed(() => {
if (!state.enabled) return true;
return !v$.value.$invalid && isHeadersValid();
});
const buildHeadersPayload = () => {
if (!state.headers.length) return {};
return state.headers.reduce((acc, header) => {
if (header.key && header.value) {
acc[header.key] = header.value;
}
return acc;
}, {});
};
const handleSubmit = async () => {
const isValid = await v$.value.$validate();
if (!isValid || !isHeadersValid()) return;
emit('submit', {
handoff_webhook_config: {
enabled: state.enabled,
url: state.url,
retry_attempts: state.retry_attempts,
timeout_seconds: state.timeout_seconds,
headers: buildHeadersPayload(),
},
});
};
const testWebhook = async () => {
isTesting.value = true;
try {
await CaptainAssistantAPI.testWebhook(props.assistant.id);
useAlert(t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.TEST_SENT'));
} catch (error) {
const message = error?.response?.data?.error || error.message;
useAlert(message);
} finally {
isTesting.value = false;
}
};
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.DESCRIPTION') }}
</p>
</div>
<Switch v-model="state.enabled" />
</div>
<div v-if="state.enabled" class="flex flex-col gap-4">
<Input
v-model="state.url"
:label="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.URL_LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.URL_PLACEHOLDER')"
required
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.HEADERS_LABEL') }}
</label>
<p class="text-xs text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.HEADERS_HELP') }}
</p>
<ul v-if="state.headers.length" class="grid gap-2 list-none">
<HeaderRow
v-for="(header, index) in state.headers"
:key="index"
ref="headersRef"
v-model:key="header.key"
v-model:value="header.value"
@remove="removeHeader(index)"
/>
</ul>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.ADD_HEADER')"
variant="faded"
color="slate"
@click="addHeader"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
v-model.number="state.retry_attempts"
type="number"
:label="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.RETRY_LABEL')"
min="0"
max="5"
/>
<Input
v-model.number="state.timeout_seconds"
type="number"
:label="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.TIMEOUT_LABEL')"
min="1"
max="30"
/>
</div>
</div>
<div class="flex gap-2">
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
:disabled="!isFormValid"
@click="handleSubmit"
/>
<Button
v-if="state.enabled"
:label="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.TEST_BUTTON')"
variant="faded"
color="slate"
:is-loading="isTesting"
:disabled="isTesting"
@click="testWebhook"
/>
</div>
</div>
</template>

View File

@ -27,6 +27,7 @@ watch(
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div class="flex flex-col gap-2">
<Input
v-if="authType === 'bearer'"

View File

@ -67,6 +67,7 @@ defineExpose({ dialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="dialogRef"
width="2xl"

View File

@ -80,6 +80,7 @@ const authTypeLabel = computed(() => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<CardLayout class="relative">
<div class="flex relative justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1 font-medium">

View File

@ -1,5 +1,5 @@
<script setup>
import { reactive, computed, useTemplateRef, watch } from 'vue';
import { reactive, computed, useTemplateRef, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
@ -10,6 +10,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ParamRow from './ParamRow.vue';
import HeaderRow from './HeaderRow.vue';
import AuthConfig from './AuthConfig.vue';
const props = defineProps({
@ -45,6 +46,7 @@ const initialState = {
};
const state = reactive({ ...initialState });
const customHeaders = ref([]);
// Populate form when in edit mode
watch(
@ -60,6 +62,15 @@ watch(
state.auth_type = newTool.auth_type || 'none';
state.auth_config = newTool.auth_config || {};
state.param_schema = newTool.param_schema || [];
// Extract headers from auth_config if present
if (state.auth_config && state.auth_config.headers) {
customHeaders.value = Object.entries(state.auth_config.headers).map(
([key, value]) => ({ key, value })
);
} else {
customHeaders.value = [];
}
}
},
{ immediate: true }
@ -114,6 +125,7 @@ const formErrors = computed(() => ({
}));
const paramsRef = useTemplateRef('paramsRef');
const headersRef = useTemplateRef('headersRef');
const isParamsValid = () => {
if (!paramsRef.value || paramsRef.value.length === 0) {
@ -122,6 +134,13 @@ const isParamsValid = () => {
return paramsRef.value.every(param => param.validate());
};
const isHeadersValid = () => {
if (!headersRef.value || headersRef.value.length === 0) {
return true;
}
return headersRef.value.every(header => header.validate());
};
const removeParam = index => {
state.param_schema.splice(index, 1);
};
@ -130,19 +149,42 @@ const addParam = () => {
state.param_schema.push({ ...DEFAULT_PARAM });
};
const removeHeader = index => {
customHeaders.value.splice(index, 1);
};
const addHeader = () => {
customHeaders.value.push({ key: '', value: '' });
};
const handleCancel = () => emit('cancel');
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid || !isParamsValid()) {
if (!isFormValid || !isParamsValid() || !isHeadersValid()) {
return;
}
// Merge headers into auth_config
if (customHeaders.value.length > 0) {
const headersHash = customHeaders.value.reduce((acc, header) => {
acc[header.key] = header.value;
return acc;
}, {});
state.auth_config = { ...state.auth_config, headers: headersHash };
} else {
// If headers exist in auth_config but are removed in UI, we need to remove them
// but preserve other auth keys.
const { headers, ...restIdx } = state.auth_config;
state.auth_config = restIdx;
}
emit('submit', state);
};
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<form
class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll"
@submit.prevent="handleSubmit"
@ -199,6 +241,34 @@ const handleSubmit = async () => {
:auth-type="state.auth_type"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADERS.LABEL') }}
</label>
<p class="text-xs text-n-slate-11 -mt-1">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADERS.HELP_TEXT') }}
</p>
<ul v-if="customHeaders.length > 0" class="grid gap-2 list-none">
<HeaderRow
v-for="(header, index) in customHeaders"
:key="index"
ref="headersRef"
v-model:key="header.key"
v-model:value="header.value"
@remove="removeHeader(index)"
/>
</ul>
<Button
type="button"
sm
ghost
blue
icon="i-lucide-plus"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ADD_HEADER')"
@click="addHeader"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }}

View File

@ -0,0 +1,81 @@
<script setup>
import { computed, defineModel, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['remove']);
const { t } = useI18n();
const showErrors = ref(false);
const key = defineModel('key', {
type: String,
required: true,
});
const value = defineModel('value', {
type: String,
required: true,
});
const validationError = computed(() => {
if (!key.value || key.value.trim() === '') {
return 'HEADER_KEY_REQUIRED';
}
if (!value.value || value.value.trim() === '') {
return 'HEADER_VALUE_REQUIRED';
}
return null;
});
watch([key, value], () => {
showErrors.value = false;
});
const validate = () => {
showErrors.value = true;
return !validationError.value;
};
defineExpose({ validate });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<li class="list-none">
<div
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"
:class="{
'animate-wiggle border-n-ruby-9': showErrors && validationError,
}"
>
<div class="flex flex-col flex-1 gap-3">
<div class="grid grid-cols-2 gap-2">
<Input
v-model="key"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_KEY.PLACEHOLDER')"
/>
<Input
v-model="value"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_VALUE.PLACEHOLDER')
"
/>
</div>
</div>
<Button
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click.stop="emit('remove')"
/>
</div>
<span
v-if="showErrors && validationError"
class="block mt-1 text-sm text-n-ruby-11"
>
{{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }}
</span>
</li>
</template>

View File

@ -61,6 +61,7 @@ defineExpose({ validate });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<li class="list-none">
<div
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"

View File

@ -59,6 +59,7 @@ defineExpose({ dialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="dialogRef"
width="3xl"

View File

@ -47,6 +47,7 @@ defineExpose({ dialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="dialogRef"
:title="$t(`${i18nKey}.TITLE`)"

View File

@ -130,6 +130,7 @@ const handleSubmit = async () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-1">
<label

View File

@ -29,6 +29,7 @@ onMounted(fetchLimits);
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Banner
v-show="showBanner"
color="amber"

View File

@ -34,6 +34,7 @@ defineExpose({ dialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="dialogRef"
type="edit"

View File

@ -15,6 +15,7 @@ const onClick = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<FeatureSpotlight
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"

View File

@ -10,6 +10,7 @@ const onClick = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<EmptyStateLayout
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"

View File

@ -18,6 +18,7 @@ const onClick = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<FeatureSpotlight
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"

View File

@ -12,6 +12,7 @@ const onClick = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<EmptyStateLayout
:title="$t('CAPTAIN.INBOXES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.INBOXES.EMPTY_STATE.SUBTITLE')"

View File

@ -39,6 +39,7 @@ const onClearFilters = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<FeatureSpotlight
v-if="isApproved"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"

View File

@ -45,6 +45,7 @@ defineExpose({ dialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="dialogRef"
type="create"

View File

@ -1,9 +1,10 @@
<script setup>
import { reactive, computed } from 'vue';
import { reactive, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import axios from 'axios';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
@ -27,9 +28,11 @@ const formState = {
const initialState = {
inboxId: null,
captainUnitId: null,
};
const state = reactive({ ...initialState });
const units = reactive([]);
const validationRules = {
inboxId: { required },
@ -46,6 +49,13 @@ const inboxList = computed(() => {
}));
});
const unitList = computed(() => {
return units.map(unit => ({
value: unit.id,
label: unit.name,
}));
});
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
@ -64,6 +74,7 @@ const handleCancel = () => emit('cancel');
const prepareInboxPayload = () => ({
inboxId: state.inboxId,
captainUnitId: state.captainUnitId,
assistantId: props.assistantId,
});
@ -75,9 +86,19 @@ const handleSubmit = async () => {
emit('submit', prepareInboxPayload());
};
onMounted(async () => {
try {
const { data } = await axios.get('/api/v1/accounts/captain/units');
units.push(...data);
} catch (error) {
// Silent fail or alert
}
});
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
@ -94,6 +115,23 @@ const handleSubmit = async () => {
/>
</div>
<!-- Unit Selection -->
<div class="flex flex-col gap-1">
<label for="unit" class="mb-0.5 text-sm font-medium text-n-slate-12">
Unidade (Opcional - Pix)
</label>
<ComboBox
id="unit"
v-model="state.captainUnitId"
:options="unitList"
placeholder="Selecione a unidade financeira"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<span class="text-xs text-n-slate-11">
Vincule a uma unidade para ativar Pix automático.
</span>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"

View File

@ -72,6 +72,7 @@ defineExpose({ dialogRef });
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="dialogRef"
:title="$t(`${i18nKey}.TITLE`)"

View File

@ -31,6 +31,7 @@ onMounted(fetchLimits);
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Banner
v-show="showBanner"
color="amber"

View File

@ -94,6 +94,7 @@ watch(
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.question"

View File

@ -3,6 +3,7 @@ import SettingsHeader from './SettingsHeader.vue';
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Story
title="Captain/PageComponents/SettingsHeader"
:layout="{ type: 'grid', width: '800px' }"

View File

@ -12,6 +12,7 @@ defineProps({
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<header class="flex flex-col items-start gap-2">
<h2 class="text-n-slate-12 text-base font-medium">{{ heading }}</h2>
<p class="text-n-slate-11 text-sm">{{ description }}</p>

View File

@ -87,6 +87,7 @@ const openCreateAssistantDialog = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>

View File

@ -229,6 +229,64 @@ const menuItems = computed(() => {
},
],
},
{
name: 'Reservations',
icon: 'i-lucide-calendar',
label: 'Reservas',
activeOn: [
'captain_reservations_index',
'captain_reminders_index',
'captain_configurations_index',
'captain_units_index',
'captain_brands_index',
'captain_pricings_index',
'captain_extras_index',
],
children: [
{
name: 'Dashboard',
label: 'Painel',
activeOn: ['captain_reservations_index'],
to: accountScopedRoute('captain_reservations_index'),
},
{
name: 'Reminders',
label: 'Lembretes',
activeOn: ['captain_reminders_index'],
to: accountScopedRoute('captain_reminders_index'),
},
{
name: 'Settings',
label: 'Configurações',
activeOn: ['captain_configurations_index'],
to: accountScopedRoute('captain_configurations_index'),
},
{
name: 'Units',
label: 'Unidades',
activeOn: ['captain_units_index'],
to: accountScopedRoute('captain_units_index'),
},
{
name: 'Brands',
label: 'Marcas',
activeOn: ['captain_brands_index'],
to: accountScopedRoute('captain_brands_index'),
},
{
name: 'Pricings',
label: 'Preços',
activeOn: ['captain_pricings_index'],
to: accountScopedRoute('captain_pricings_index'),
},
{
name: 'Extras',
label: 'Extras',
activeOn: ['captain_extras_index'],
to: accountScopedRoute('captain_extras_index'),
},
],
},
{
name: 'Captain',
icon: 'i-woot-captain',
@ -254,6 +312,12 @@ const menuItems = computed(() => {
navigationPath: 'captain_assistants_documents_index',
}),
},
{
name: 'Assets',
label: t('SIDEBAR.CAPTAIN_ASSETS'),
activeOn: ['captain_assets_index'],
to: accountScopedRoute('captain_assets_index'),
},
{
name: 'Scenarios',
label: t('SIDEBAR.CAPTAIN_SCENARIOS'),

View File

@ -1,5 +1,6 @@
<script setup>
import router from '../../routes/index';
import { useRouter } from 'vue-router';
const props = defineProps({
backUrl: {
type: [String, Object],
@ -15,6 +16,8 @@ const props = defineProps({
},
});
const router = useRouter();
const goBack = () => {
if (props.backUrl !== '') {
router.push(props.backUrl);

View File

@ -3,6 +3,7 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_actions' },
{ name: 'captain_reservations' },
{ name: 'macros' },
{ name: 'conversation_info' },
{ name: 'contact_attributes' },

View File

@ -421,6 +421,7 @@
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros",
"CAPTAIN_RESERVATIONS": "Reservations & Reminders",
"LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders"
},

View File

@ -547,6 +547,19 @@
"ALLOW_CITATIONS": "Include source citations in responses",
"ALLOW_SENTIMENT_HANDOFF": "Automatically handoff to human on negative sentiment (angry/frustrated)"
},
"WEBHOOK": {
"TITLE": "Handoff Webhook",
"DESCRIPTION": "Notify external systems when a conversation is handed off to a human agent",
"URL_LABEL": "Webhook URL",
"URL_PLACEHOLDER": "https://api.example.com/handoff",
"HEADERS_LABEL": "Custom Headers (Optional)",
"HEADERS_HELP": "Add custom headers for authentication (e.g., Authorization: Bearer TOKEN)",
"ADD_HEADER": "+ Add Header",
"RETRY_LABEL": "Retry Attempts",
"TIMEOUT_LABEL": "Timeout (seconds)",
"TEST_BUTTON": "Test Webhook",
"TEST_SENT": "Test webhook sent!"
},
"LLM_PROVIDER": {
"LABEL": "LLM Provider"
},
@ -845,6 +858,169 @@
}
}
},
"ASSETS": {
"HEADER": "Media Gallery",
"DESCRIPTION": "Upload images and reference them in prompts using {{ media.key }}.",
"ADD_NEW": "Upload image",
"EMPTY_STATE": "No media assets yet. Upload your first image to get started.",
"FORM_DESCRIPTION": "Upload an image and set a unique key to use in prompts.",
"SHORTCUT_LABEL": "Shortcut",
"COPY_SHORTCUT": {
"LABEL": "Copy shortcut",
"SUCCESS": "Shortcut copied"
},
"COPY_URL": {
"LABEL": "Copy URL",
"SUCCESS": "URL copied"
},
"FORM": {
"NAME": {
"LABEL": "Key",
"PLACEHOLDER": "suite_master",
"ERROR": "Key is required"
},
"FILE": {
"LABEL": "Image file",
"CHOOSE_FILE": "Choose image",
"ERROR": "Image is required",
"HELP_TEXT": "PNG, JPG, GIF, or WEBP up to 10MB",
"INVALID_TYPE": "Only PNG, JPG, GIF, or WEBP files are allowed",
"TOO_LARGE": "File must be smaller than 10MB"
}
},
"CREATE": {
"TITLE": "Upload image",
"SUCCESS_MESSAGE": "Image uploaded successfully",
"ERROR_MESSAGE": "There was an error uploading the image"
},
"EDIT": {
"TITLE": "Update image",
"SUCCESS_MESSAGE": "Image updated successfully",
"ERROR_MESSAGE": "There was an error updating the image"
},
"DELETE": {
"TITLE": "Delete image",
"DESCRIPTION": "This action is permanent. Deleting this image will break any prompts that reference it.",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "Image deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the image"
}
},
"RESERVATIONS": {
"AUTOMATIONS": {
"TITLE": "Automations",
"LIST_TITLE": "Existing automations",
"EMPTY": "No automations configured",
"EDIT": "Edit",
"DELETE": "Delete",
"TRIGGER_CHECK_IN": "Check-in",
"TRIGGER_CHECK_OUT": "Check-out",
"TIMING_BEFORE": "Before",
"TIMING_AFTER": "After",
"FORM": {
"TITLE": "Title",
"MESSAGE": "Message",
"TRIGGER": "Trigger",
"TIMING": "Timing",
"MINUTES": "Minutes",
"SUBMIT": "Add automation",
"UPDATE": "Update automation",
"CANCEL": "Cancel"
},
"SUCCESS": {
"CREATED": "Automation created",
"UPDATED": "Automation updated",
"DELETED": "Automation deleted"
},
"ERRORS": {
"LOAD_FAILED": "Could not load automations",
"MISSING_FIELDS": "Fill in title and message",
"CREATE_FAILED": "Could not create automation",
"UPDATE_FAILED": "Could not update automation",
"DELETE_FAILED": "Could not delete automation"
}
},
"PAGE": {
"HEADER": "Reservations & Reminders",
"DESCRIPTION": "Create manual reservations and reminders tied to an inbox.",
"INBOX_LABEL": "Inbox",
"INBOX_PLACEHOLDER": "Select an inbox",
"NO_INBOXES": "No inboxes available",
"RESERVATION_TITLE": "Manual reservation",
"CONTACT_NAME_LABEL": "Customer name (optional)",
"CONTACT_NAME_PLACEHOLDER": "Maria",
"PHONE_LABEL": "Customer WhatsApp",
"PHONE_PLACEHOLDER": "+55 11 99999-9999",
"NO_RESERVATIONS": "No reservations for this inbox",
"FILTERS_TITLE": "Filters",
"FILTERS_FROM": "From",
"FILTERS_TO": "To",
"LOAD_MORE": "Load more",
"ERRORS": {
"INBOX_REQUIRED": "Select an inbox",
"PHONE_REQUIRED": "Phone number is required"
}
},
"SECTION_TITLE": "Reservation",
"LIST_TITLE": "Upcoming reservations",
"NO_SUITE": "Suite not set",
"FORM": {
"SUITE_LABEL": "Suite",
"SUITE_PLACEHOLDER": "102",
"CHECK_IN_LABEL": "Check-in time",
"DURATION_LABEL": "Duration (hours)",
"CHECK_OUT_PREVIEW": "Estimated check-out",
"SUBMIT": "Create reservation"
},
"SETTINGS": {
"TITLE": "Automation messages",
"ENABLED": "Enable automations",
"MENU_DELAY": "Menu delay (minutes)",
"MENU_MESSAGE": "Menu message",
"FEEDBACK_DELAY": "Feedback delay (minutes)",
"FEEDBACK_MESSAGE": "Feedback message",
"SAVE": "Save settings",
"SAVED": "Settings updated"
},
"SUCCESS": {
"CREATED": "Reservation created",
"CANCELLED": "Reservation cancelled"
},
"ERRORS": {
"LOAD_FAILED": "Could not load reservations",
"CREATE_FAILED": "Could not create reservation",
"MISSING_RESERVATION_FIELDS": "Fill in check-in and duration",
"CANCEL_FAILED": "Could not cancel reservation",
"SETTINGS_LOAD_FAILED": "Could not load automation settings",
"SETTINGS_SAVE_FAILED": "Could not save automation settings"
}
},
"REMINDERS": {
"PAGE": {
"TITLE": "Manual reminder",
"NO_REMINDERS": "No reminders for this inbox",
"LOAD_MORE": "Load more"
},
"SECTION_TITLE": "Reminder",
"LIST_TITLE": "Scheduled reminders",
"FORM": {
"MESSAGE_LABEL": "Message",
"MESSAGE_PLACEHOLDER": "Your suite is available...",
"TIME_LABEL": "Send at",
"SUBMIT": "Schedule reminder"
},
"SUCCESS": {
"CREATED": "Reminder scheduled",
"CANCELLED": "Reminder cancelled"
},
"ERRORS": {
"LOAD_FAILED": "Could not load reminders",
"MISSING_FIELDS": "Fill in message and time",
"PHONE_REQUIRED": "Phone number is required",
"CREATE_FAILED": "Could not schedule reminder",
"CANCEL_FAILED": "Could not cancel reminder"
}
},
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
@ -949,8 +1125,21 @@
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
},
"HEADERS": {
"LABEL": "Custom Headers",
"HELP_TEXT": "Define static headers that will be sent with every request (e.g. Authorization, PLUG-PLAY-TOKEN)."
},
"ADD_HEADER": "Add Header",
"HEADER_KEY": {
"PLACEHOLDER": "Header Name (e.g. X-Auth-Token)"
},
"HEADER_VALUE": {
"PLACEHOLDER": "Value"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
"PARAM_NAME_REQUIRED": "Parameter name is required",
"HEADER_KEY_REQUIRED": "Header name is required",
"HEADER_VALUE_REQUIRED": "Header value is required"
}
}
},
@ -1043,6 +1232,12 @@
"INBOXES": {
"HEADER": "Connected Inboxes",
"ADD_NEW": "Connect a new inbox",
"REMINDER_TOOL": {
"LABEL": "Always use reminder tool",
"HELP": "When enabled, the assistant must schedule reminders using the reminder tool.",
"SUCCESS": "Reminder tool preference updated",
"ERROR": "Could not update reminder tool preference"
},
"OPTIONS": {
"DISCONNECT": "Disconnect"
},

View File

@ -312,6 +312,8 @@
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_ASSETS": "Media Gallery",
"CAPTAIN_RESERVATIONS": "Reservations",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"CAPTAIN_SCENARIOS": "Scenarios",

View File

@ -411,6 +411,7 @@
"CONTACT_ATTRIBUTES": "Atributos do contato",
"PREVIOUS_CONVERSATION": "Conversas anteriores",
"MACROS": "Macros",
"CAPTAIN_RESERVATIONS": "Reservas e lembretes",
"LINEAR_ISSUES": "Problemas do Linear vinculados",
"SHOPIFY_ORDERS": "Shopify Orders"
},

View File

@ -147,7 +147,8 @@
"MAKE_FORMAL": "Usar tom formal",
"SIMPLIFY": "Simplificar"
},
"ASSISTANCE_MODAL": {
"ASSISTANT_GENERATOR": {
"DRAFT_TITLE": "Conteúdo do rascunho",
"GENERATED_TITLE": "Conteúdo gerado",
"AI_WRITING": "A Inteligência Artificial está escrevendo",
@ -366,6 +367,41 @@
},
"CAPTAIN": {
"NAME": "Capitão",
"UNITS": {
"TITLE": "Unidades & Filiais",
"HEADER": "Gerenciar Unidades",
"DESCRIPTION": "Gerencie as unidades físicas (motéis/hotéis) e suas chaves Pix.",
"NEW_UNIT": "Nova Unidade",
"ADD_NEW": "Adicionar Nova Unidade",
"ADD_TITLE": "Adicionar Nova Unidade",
"ADD_DESC": "Cadastre uma nova filial para gerenciar reservas e pagamentos.",
"EDIT_TITLE": "Editar Unidade",
"EDIT_DESC": "Atualize as informações e configurações da unidade.",
"NAME_REQUIRED": "O nome da unidade é obrigatório",
"SAVED_SUCCESS": "Unidade salva com sucesso!",
"UPDATE_ERROR": "Erro ao atualizar unidade.",
"COPY_LINK": "Copiar Link",
"OPEN_PAGE": "Abrir Página",
"COPY_LINK_SUCCESS": "Link copiado com sucesso!",
"COPY_LINK_ERROR": "Erro ao copiar o link.",
"SAVED_ERROR": "Erro ao salvar unidade.",
"ERROR_FETCHING": "Erro ao buscar unidades",
"FORM": {
"NAME_LABEL": "Nome da Unidade",
"NAME_PLACEHOLDER": "Ex: Unidade Centro",
"NAME_ERROR": "O nome é obrigatório",
"BRAND_LABEL": "Marca",
"BRAND_PLACEHOLDER": "Selecione uma marca",
"FORM_DESCRIPTION": "Preencha os dados da unidade e as credenciais do Pix (Banco Inter).",
"PIX_CONFIG_TITLE": "Configuração Pix (Banco Inter)",
"PIX_KEY": "Chave Pix",
"CLIENT_ID": "Client ID",
"CLIENT_SECRET": "Client Secret",
"CERT_PATH": "Caminho do Certificado (.pem)",
"KEY_PATH": "Caminho da Chave (.pem)",
"ACCOUNT_NUMBER": "Conta Corrente"
}
},
"HEADER_KNOW_MORE": "Know more",
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistentes",
@ -535,6 +571,19 @@
"ALLOW_MEMORIES": "Capture os principais detalhes como memórias de interações do cliente.",
"ALLOW_CITATIONS": "Incluir fonte de citações nas respostas",
"ALLOW_SENTIMENT_HANDOFF": "Transferir automaticamente para humano em caso de sentimento negativo (raiva/frustração)"
},
"WEBHOOK": {
"TITLE": "Webhook de Escalação",
"DESCRIPTION": "Notifique sistemas externos quando uma conversa for escalada para atendimento humano",
"URL_LABEL": "URL do Webhook",
"URL_PLACEHOLDER": "https://api.example.com/handoff",
"HEADERS_LABEL": "Headers Personalizados (Opcional)",
"HEADERS_HELP": "Adicione headers customizados para autenticação (ex: Authorization: Bearer TOKEN)",
"ADD_HEADER": "+ Adicionar Header",
"RETRY_LABEL": "Tentativas de Retry",
"TIMEOUT_LABEL": "Timeout (segundos)",
"TEST_BUTTON": "Testar Webhook",
"TEST_SENT": "Webhook de teste enviado!"
}
},
"EDIT": {
@ -822,6 +871,172 @@
}
}
},
"ASSETS": {
"HEADER": "Galeria de Midia",
"DESCRIPTION": "Envie imagens e referencie nos prompts usando {{ media.chave }}.",
"ADD_NEW": "Enviar imagem",
"EMPTY_STATE": "Nenhuma imagem ainda. Envie sua primeira imagem para comecar.",
"FORM_DESCRIPTION": "Envie uma imagem e defina uma chave unica para usar nos prompts.",
"SHORTCUT_LABEL": "Atalho",
"COPY_SHORTCUT": {
"LABEL": "Copiar atalho",
"SUCCESS": "Atalho copiado"
},
"COPY_URL": {
"LABEL": "Copiar URL",
"SUCCESS": "URL copiada"
},
"FORM": {
"NAME": {
"LABEL": "Chave",
"PLACEHOLDER": "suite_master",
"ERROR": "A chave e obrigatoria"
},
"FILE": {
"LABEL": "Arquivo de imagem",
"CHOOSE_FILE": "Escolher imagem",
"ERROR": "A imagem e obrigatoria",
"HELP_TEXT": "PNG, JPG, GIF ou WEBP ate 10MB",
"INVALID_TYPE": "Apenas arquivos PNG, JPG, GIF ou WEBP sao permitidos",
"TOO_LARGE": "O arquivo deve ter menos de 10MB"
}
},
"CREATE": {
"TITLE": "Enviar imagem",
"SUCCESS_MESSAGE": "Imagem enviada com sucesso",
"ERROR_MESSAGE": "Ocorreu um erro ao enviar a imagem"
},
"EDIT": {
"TITLE": "Atualizar imagem",
"SUCCESS_MESSAGE": "Imagem atualizada com sucesso",
"ERROR_MESSAGE": "Ocorreu um erro ao atualizar a imagem"
},
"DELETE": {
"TITLE": "Excluir imagem",
"DESCRIPTION": "Esta acao e permanente. Excluir esta imagem quebrara prompts que a referenciam.",
"CONFIRM": "Sim, excluir",
"SUCCESS_MESSAGE": "Imagem excluida com sucesso",
"ERROR_MESSAGE": "Ocorreu um erro ao excluir a imagem"
}
},
"RESERVATIONS": {
"AUTOMATIONS": {
"TITLE": "Automacoes",
"LIST_TITLE": "Automacoes existentes",
"EMPTY": "Nenhuma automacao configurada",
"EDIT": "Editar",
"DELETE": "Excluir",
"TRIGGER_CHECK_IN": "Check-in",
"TRIGGER_CHECK_OUT": "Check-out",
"TIMING_BEFORE": "Antes",
"TIMING_AFTER": "Depois",
"FORM": {
"TITLE": "Titulo",
"MESSAGE": "Mensagem",
"TRIGGER": "Disparo",
"TIMING": "Momento",
"MINUTES": "Minutos",
"SUBMIT": "Adicionar automacao",
"UPDATE": "Atualizar automacao",
"CANCEL": "Cancelar"
},
"SUCCESS": {
"CREATED": "Automacao criada",
"UPDATED": "Automacao atualizada",
"DELETED": "Automacao excluida"
},
"ERRORS": {
"LOAD_FAILED": "Nao foi possivel carregar as automacoes",
"MISSING_FIELDS": "Preencha titulo e mensagem",
"CREATE_FAILED": "Nao foi possivel criar a automacao",
"UPDATE_FAILED": "Nao foi possivel atualizar a automacao",
"DELETE_FAILED": "Nao foi possivel excluir a automacao"
}
},
"PAGE": {
"HEADER": "Reservas e lembretes",
"DESCRIPTION": "Crie reservas e lembretes manuais vinculados a uma caixa de entrada.",
"INBOX_LABEL": "Caixa de entrada",
"INBOX_PLACEHOLDER": "Selecione uma caixa de entrada",
"NO_INBOXES": "Nenhuma caixa de entrada disponível",
"RESERVATION_TITLE": "Reserva manual",
"CONTACT_NAME_LABEL": "Nome do cliente (opcional)",
"CONTACT_NAME_PLACEHOLDER": "Maria",
"PHONE_LABEL": "WhatsApp do cliente",
"PHONE_PLACEHOLDER": "+55 11 99999-9999",
"NO_RESERVATIONS": "Nenhuma reserva para esta caixa de entrada",
"FILTERS_TITLE": "Filtros",
"FILTERS_FROM": "De",
"FILTERS_TO": "Ate",
"LOAD_MORE": "Carregar mais",
"ERRORS": {
"INBOX_REQUIRED": "Selecione uma caixa de entrada",
"PHONE_REQUIRED": "Telefone obrigatorio"
}
},
"SECTION_TITLE": "Reserva",
"LIST_TITLE": "Reservas futuras",
"NO_SUITE": "Suite nao informada",
"FORM": {
"UNIT_LABEL": "Unidade",
"UNIT_PLACEHOLDER": "Selecione a unidade",
"TOTAL_AMOUNT_LABEL": "Valor Total (R$)",
"SUITE_LABEL": "Suite",
"SUITE_PLACEHOLDER": "102",
"CHECK_IN_LABEL": "Horario de entrada",
"DURATION_LABEL": "Duracao (horas)",
"CHECK_OUT_PREVIEW": "Horario estimado de saida",
"SUBMIT": "Criar reserva"
},
"SETTINGS": {
"TITLE": "Mensagens automaticas",
"ENABLED": "Ativar automacoes",
"MENU_DELAY": "Atraso do cardapio (minutos)",
"MENU_MESSAGE": "Mensagem do cardapio",
"FEEDBACK_DELAY": "Atraso da pesquisa (minutos)",
"FEEDBACK_MESSAGE": "Mensagem da pesquisa",
"SAVE": "Salvar configuracoes",
"SAVED": "Configuracoes atualizadas"
},
"SUCCESS": {
"CREATED": "Reserva criada",
"CANCELLED": "Reserva cancelada"
},
"ERRORS": {
"LOAD_FAILED": "Nao foi possivel carregar as reservas",
"CREATE_FAILED": "Nao foi possivel criar a reserva",
"MISSING_RESERVATION_FIELDS": "Preencha horario de entrada e duracao",
"CANCEL_FAILED": "Nao foi possivel cancelar a reserva",
"SETTINGS_LOAD_FAILED": "Nao foi possivel carregar as configuracoes",
"SETTINGS_SAVE_FAILED": "Nao foi possivel salvar as configuracoes"
}
},
"REMINDERS": {
"PAGE": {
"TITLE": "Lembrete manual",
"NO_REMINDERS": "Nenhum lembrete para esta caixa de entrada",
"LOAD_MORE": "Carregar mais"
},
"SECTION_TITLE": "Lembrete",
"LIST_TITLE": "Lembretes agendados",
"FORM": {
"MESSAGE_LABEL": "Mensagem",
"MESSAGE_PLACEHOLDER": "Sua suite esta disponivel...",
"TIME_LABEL": "Enviar em",
"SUBMIT": "Agendar lembrete"
},
"SUCCESS": {
"CREATED": "Lembrete agendado",
"CANCELLED": "Lembrete cancelado"
},
"ERRORS": {
"LOAD_FAILED": "Nao foi possivel carregar os lembretes",
"MISSING_FIELDS": "Preencha mensagem e horario",
"PHONE_REQUIRED": "Telefone obrigatorio",
"CREATE_FAILED": "Nao foi possivel agendar o lembrete",
"CANCEL_FAILED": "Nao foi possivel cancelar o lembrete"
}
},
"CUSTOM_TOOLS": {
"HEADER": "Poderes",
"ADD_NEW": "Criar poder",
@ -926,8 +1141,21 @@
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
},
"HEADERS": {
"LABEL": "Headers Personalizados",
"HELP_TEXT": "Defina cabeçalhos estáticos para autenticação e segurança (Ex: PLUG-PLAY-TOKEN)."
},
"ADD_HEADER": "Adicionar Header",
"HEADER_KEY": {
"PLACEHOLDER": "Nome do Header (Ex: X-Auth-Token)"
},
"HEADER_VALUE": {
"PLACEHOLDER": "Valor"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
"PARAM_NAME_REQUIRED": "Nome do parâmetro obrigatório",
"HEADER_KEY_REQUIRED": "Nome do header obrigatório",
"HEADER_VALUE_REQUIRED": "Valor do header obrigatório"
}
}
},
@ -1020,6 +1248,12 @@
"INBOXES": {
"HEADER": "Caixas de entrada conectadas",
"ADD_NEW": "Conectar uma nova caixa de entrada",
"REMINDER_TOOL": {
"LABEL": "Sempre usar a ferramenta de lembretes",
"HELP": "Quando ativo, o assistente deve agendar lembretes usando a ferramenta.",
"SUCCESS": "Preferencia de lembretes atualizada",
"ERROR": "Nao foi possivel atualizar a preferencia"
},
"OPTIONS": {
"DISCONNECT": "Desconectar"
},

View File

@ -312,6 +312,8 @@
"CAPTAIN": "Capitão",
"CAPTAIN_ASSISTANTS": "Assistentes",
"CAPTAIN_DOCUMENTS": "Documentos",
"CAPTAIN_ASSETS": "Galeria de Midia",
"CAPTAIN_RESERVATIONS": "Reservas",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Poderes",
"CAPTAIN_SCENARIOS": "Cenários",

View File

@ -0,0 +1,143 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import AssetCard from 'dashboard/components-next/captain/pageComponents/asset/AssetCard.vue';
import CreateAssetDialog from 'dashboard/components-next/captain/pageComponents/asset/CreateAssetDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import EditAssetDialog from 'dashboard/components-next/captain/pageComponents/asset/EditAssetDialog.vue';
const store = useStore();
const uiFlags = useMapGetter('captainAssets/getUIFlags');
const assets = useMapGetter('captainAssets/getRecords');
const assetsMeta = useMapGetter('captainAssets/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedAsset = ref(null);
const deleteAssetDialog = ref(null);
const showCreateDialog = ref(false);
const createAssetDialog = ref(null);
const showEditDialog = ref(false);
const editAssetDialog = ref(null);
const fetchAssets = (page = 1) => {
store.dispatch('captainAssets/get', { page });
};
const handleCreateAsset = () => {
showCreateDialog.value = true;
nextTick(() => createAssetDialog.value.dialogRef.open());
};
const handleCreateDialogClose = () => {
showCreateDialog.value = false;
};
const handleEditDialogClose = () => {
showEditDialog.value = false;
};
const handleDelete = () => {
deleteAssetDialog.value.dialogRef.open();
};
const handleAction = ({ action, id }) => {
selectedAsset.value = assets.value.find(asset => id === asset.id);
if (action === 'delete') {
handleDelete();
} else if (action === 'edit') {
showEditDialog.value = true;
nextTick(() => editAssetDialog.value.dialogRef.open());
}
};
const onDeleteSuccess = () => {
if (assets.value?.length === 0 && assetsMeta.value?.page > 1) {
fetchAssets(assetsMeta.value.page - 1);
}
};
const onPageChange = page => fetchAssets(page);
onMounted(() => {
fetchAssets();
});
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<PageLayout
:header-title="$t('CAPTAIN.ASSETS.HEADER')"
:header-description="$t('CAPTAIN.ASSETS.DESCRIPTION')"
:button-label="$t('CAPTAIN.ASSETS.ADD_NEW')"
:button-policy="['administrator']"
:total-count="assetsMeta.totalCount"
:current-page="assetsMeta.page"
:show-pagination-footer="!isFetching && !!assets.length"
:is-fetching="isFetching"
:is-empty="!assets.length"
:show-know-more="false"
:show-assistant-switcher="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@update:current-page="onPageChange"
@click="handleCreateAsset"
>
<template #paywall>
<CaptainPaywall />
</template>
<template #emptyState>
<div class="flex flex-col items-center gap-3 py-8 text-center">
<i class="text-3xl i-ph-image text-n-slate-11" />
<p class="text-sm text-n-slate-11">
{{ $t('CAPTAIN.ASSETS.EMPTY_STATE') }}
</p>
<Button
:label="$t('CAPTAIN.ASSETS.ADD_NEW')"
icon="i-lucide-plus"
size="sm"
@click="handleCreateAsset"
/>
</div>
</template>
<template #body>
<div class="flex flex-col gap-4">
<AssetCard
v-for="asset in assets"
:id="asset.id"
:key="asset.id"
:name="asset.name"
:file-url="asset.file_url"
:created-at="asset.created_at"
@action="handleAction"
/>
</div>
</template>
<CreateAssetDialog
v-if="showCreateDialog"
ref="createAssetDialog"
@close="handleCreateDialogClose"
/>
<EditAssetDialog
v-if="showEditDialog && selectedAsset"
ref="editAssetDialog"
:asset="selectedAsset"
@close="handleEditDialogClose"
/>
<DeleteDialog
v-if="selectedAsset"
ref="deleteAssetDialog"
type="Assets"
translation-key="ASSETS"
:entity="selectedAsset"
@delete-success="onDeleteSuccess"
/>
</PageLayout>
</template>

View File

@ -46,6 +46,7 @@ const handleAfterCreate = newAssistant => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.HEADER')"
:show-pagination-footer="false"

View File

@ -173,6 +173,7 @@ const addAllExample = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.TITLE')"
:is-fetching="isFetching"

View File

@ -180,6 +180,7 @@ const addAllExample = async () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
:is-fetching="isFetching"

View File

@ -57,6 +57,7 @@ onMounted(() =>
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<PageLayout
:header-title="$t('CAPTAIN.INBOXES.HEADER')"
:button-label="$t('CAPTAIN.INBOXES.ADD_NEW')"
@ -79,6 +80,7 @@ onMounted(() =>
:id="captainInbox.id"
:key="captainInbox.id"
:inbox="captainInbox"
:assistant-id="assistantId"
@action="handleAction"
/>
</div>

View File

@ -9,6 +9,7 @@ const assistantId = computed(() => Number(route.params.assistantId));
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<PageLayout
show-assistant-switcher
:show-pagination-footer="false"

View File

@ -198,6 +198,7 @@ onMounted(() => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<PageLayout
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
:is-fetching="isFetching"
@ -241,7 +242,7 @@ onMounted(() => {
/>
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ item.tools?.map(tool => `@${tool}`).join(', ')}}
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
</span>
</div>
</template>

View File

@ -12,6 +12,7 @@ import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import AssistantBasicSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue';
import AssistantSystemSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue';
import AssistantWebhookSettings from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantWebhookSettings.vue';
import AssistantControlItems from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantControlItems.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
@ -57,7 +58,8 @@ const controlItems = computed(() => {
},
{
name: 'Assistant Skills',
description: 'Configure external tools and integrations available to this assistant.',
description:
'Configure external tools and integrations available to this assistant.',
routeName: 'captain_tools_index',
},
];
@ -108,6 +110,7 @@ const handleDeleteSuccess = () => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<PageLayout
:is-fetching="isFetching"
:show-pagination-footer="false"
@ -149,6 +152,13 @@ const handleDeleteSuccess = () => {
/>
</div>
<span class="h-px w-full bg-n-weak mt-2" />
<div class="flex flex-col gap-6">
<AssistantWebhookSettings
:assistant="assistant"
@submit="handleSubmit"
/>
</div>
<span class="h-px w-full bg-n-weak mt-2" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-n-slate-12 text-base font-medium">

View File

@ -117,6 +117,7 @@ onMounted(() => {
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.SKILLS.HEADER')"
:header-description="$t('CAPTAIN.ASSISTANTS.SKILLS.DESCRIPTION')"
@ -152,7 +153,7 @@ onMounted(() => {
</div>
<div
v-if="tool.enabled"
v-if="tool.enabled && tool.key !== 'react_to_message'"
class="flex flex-col gap-4 pl-4 border-l-2 border-n-weak mt-6 pt-2 transition-all"
>
<h5

View File

@ -0,0 +1,204 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Modal from 'dashboard/components/Modal.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
brand: {
type: Object,
default: null,
},
});
const emit = defineEmits(['close', 'save']);
const { t } = useI18n(); // eslint-disable-line no-unused-vars
const name = ref('');
// suiteItems will hold objects: { name: 'Standard', image: 'url' }
const suiteItems = ref([]);
const stayDurations = ref('');
const resetForm = () => {
name.value = '';
suiteItems.value = [{ name: '', image: '' }];
stayDurations.value = '';
};
watch(
() => props.brand,
newBrand => {
if (newBrand) {
name.value = newBrand.name;
// Parse suite categories and images (handle both snake_case and camelCase)
const categories =
newBrand.suite_categories || newBrand.suiteCategories || [];
const images = newBrand.suite_images || newBrand.suiteImages || {};
if (Array.isArray(categories) && categories.length > 0) {
suiteItems.value = categories.map(cat => ({
name: cat,
image: images[cat] || '',
}));
} else if (typeof categories === 'string') {
// Handle legacy string format if exists
suiteItems.value = categories
.split(',')
.map(s => ({ name: s.trim(), image: '' }));
} else {
suiteItems.value = [{ name: '', image: '' }];
}
const durations = newBrand.stay_durations || newBrand.stayDurations;
stayDurations.value = Array.isArray(durations)
? durations.join(', ')
: durations || '';
} else {
resetForm();
}
},
{ immediate: true }
);
const addSuiteItem = () => {
suiteItems.value.push({ name: '', image: '' });
};
const removeSuiteItem = index => {
suiteItems.value.splice(index, 1);
};
const onClose = () => {
emit('close');
resetForm();
};
const onSave = () => {
// Convert suiteItems back to separate structures
const validItems = suiteItems.value.filter(item => item.name.trim() !== '');
const categories = validItems.map(item => item.name.trim());
const images = {};
validItems.forEach(item => {
if (item.image && item.image.trim() !== '') {
images[item.name.trim()] = item.image.trim();
}
});
const payload = {
name: name.value,
suite_categories: categories,
suite_images: images,
stay_durations: stayDurations.value
.split(',')
.map(s => s.trim())
.filter(s => s),
};
emit('save', payload);
onClose();
};
const headerTitle = computed(() =>
props.brand ? 'Editar Marca' : 'Nova Marca'
);
const saveLabel = computed(() => (props.brand ? 'Atualizar' : 'Criar'));
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Modal :show="show" :on-close="onClose">
<div
class="flex flex-col gap-4 p-6 w-[600px] bg-white dark:bg-slate-900 rounded-lg"
>
<h2 class="text-xl font-semibold text-slate-800 dark:text-slate-100">
{{ headerTitle }}
</h2>
<div class="flex flex-col gap-4">
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1"
>
Nome da Marca
</label>
<Input v-model="name" placeholder="Ex: Hotel Prime" />
</div>
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1"
>
Categorias de Suíte e Fotos
</label>
<div
class="flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2 mb-2"
>
<div
v-for="(item, index) in suiteItems"
:key="index"
class="flex gap-2 items-start"
>
<div class="flex-1">
<Input
v-model="item.name"
placeholder="Nome (Ex: Presidencial)"
/>
</div>
<div class="flex-1">
<Input
v-model="item.image"
placeholder="URL da Imagem (https://...)"
/>
</div>
<button
class="mt-2 text-red-500 hover:text-red-700 p-1"
title="Remover"
@click="removeSuiteItem(index)"
>
<i class="i-lucide-trash-2" />
</button>
</div>
</div>
<button
class="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1 font-medium bg-transparent border-none p-0 cursor-pointer"
@click="addSuiteItem"
>
<i class="i-lucide-plus" /> Adicionar Categoria
</button>
<p class="text-xs text-slate-500 mt-2 dark:text-slate-400">
Insira o nome da categoria e opcionalmente a URL da foto.
</p>
</div>
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1"
>
Permanências (separadas por vírgula)
</label>
<Input
v-model="stayDurations"
placeholder="Ex: 2h, 4h, Pernoite, Diária"
/>
</div>
</div>
<div
class="flex justify-end gap-2 mt-4 pt-4 border-t border-slate-100 dark:border-slate-800"
>
<Button variant="ghost" @click="onClose"> Cancelar </Button>
<Button @click="onSave">
{{ saveLabel }}
</Button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,242 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useRoute } from 'vue-router';
import BrandModal from './BrandModal.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const route = useRoute();
const accountId = route.params.accountId;
const brands = ref([]);
const isLoading = ref(false);
const showModal = ref(false);
const selectedBrand = ref(null);
const fetchBrands = async () => {
isLoading.value = true;
try {
const response = await window.axios.get(
`/api/v1/accounts/${accountId}/captain/brands`
);
brands.value = response.data;
} catch (error) {
useAlert('Erro ao buscar marcas');
} finally {
isLoading.value = false;
}
};
const openAddModal = () => {
selectedBrand.value = null;
showModal.value = true;
};
const openEditModal = brand => {
selectedBrand.value = brand;
showModal.value = true;
};
const deleteBrand = async brandId => {
// eslint-disable-next-line no-alert, no-restricted-globals
if (!confirm('Tem certeza que deseja excluir esta marca?')) return;
try {
await window.axios.delete(
`/api/v1/accounts/${accountId}/captain/brands/${brandId}`
);
brands.value = brands.value.filter(b => b.id !== brandId);
useAlert('Marca excluída com sucesso');
} catch (error) {
useAlert('Erro ao excluir marca');
}
};
const handleSave = async brandData => {
try {
let response;
if (selectedBrand.value) {
// Update existing brand
response = await window.axios.put(
`/api/v1/accounts/${accountId}/captain/brands/${selectedBrand.value.id}`,
{ brand: brandData }
);
const index = brands.value.findIndex(
b => b.id === selectedBrand.value.id
);
if (index !== -1) {
brands.value[index] = response.data;
}
useAlert('Marca atualizada com sucesso');
} else {
// Create new brand
response = await window.axios.post(
`/api/v1/accounts/${accountId}/captain/brands`,
{ brand: brandData }
);
brands.value.push(response.data);
useAlert('Marca criada com sucesso');
}
showModal.value = false;
} catch (error) {
useAlert('Erro ao salvar marca');
}
};
const joinList = list => {
if (!list) return '';
return list.join(', ');
};
onMounted(fetchBrands);
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<div
class="flex flex-col h-full w-full bg-slate-50 dark:bg-slate-900 px-8 py-8 overflow-y-auto"
>
<div class="flex-1 w-full">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-slate-100">
Painel Administrativo
</h1>
</div>
<div
class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 w-full"
>
<div
class="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center bg-white dark:bg-slate-800 rounded-t-lg"
>
<h2 class="text-lg font-medium text-slate-800 dark:text-slate-100">
Gerenciar Marcas
</h2>
<Button
variant="smooth"
size="sm"
class="flex items-center gap-2"
@click="openAddModal"
>
<i class="i-lucide-plus" />
Adicionar Nova Marca
</Button>
</div>
<div v-if="isLoading" class="p-8 flex justify-center">
<Spinner />
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead
class="bg-slate-50 dark:bg-slate-700/50 text-slate-500 dark:text-slate-300 uppercase font-medium"
>
<tr>
<th class="px-6 py-4 w-1/4">Nome</th>
<th class="px-6 py-4 w-1/3">Categorias</th>
<th class="px-6 py-4 w-1/4">Permanências</th>
<th class="px-6 py-4 text-right">Ações</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr
v-for="brand in brands"
:key="brand.id"
class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<td
class="px-6 py-4 font-medium text-slate-900 dark:text-slate-100 align-top"
>
{{ brand.name }}
</td>
<td
class="px-6 py-4 text-slate-600 dark:text-slate-300 align-top"
>
<div class="flex flex-col gap-2">
<div
v-for="(cat, idx) in brand.suiteCategories ||
brand.suite_categories ||
[]"
:key="idx"
class="break-words border-b border-slate-100 dark:border-slate-700/50 last:border-0 pb-1 last:pb-0"
>
<span class="font-medium">{{ cat }}</span>
<div
v-if="
(brand.suiteImages || brand.suite_images) &&
(brand.suiteImages || brand.suite_images)[cat]
"
class="text-xs text-blue-500 mt-0.5 truncate max-w-[300px]"
:title="(brand.suiteImages || brand.suite_images)[cat]"
>
<a
:href="(brand.suiteImages || brand.suite_images)[cat]"
target="_blank"
rel="noopener noreferrer"
class="hover:underline flex items-center gap-1"
>
<i class="i-lucide-link size-3" />
Ver Imagem
</a>
</div>
</div>
</div>
</td>
<td
class="px-6 py-4 text-slate-600 dark:text-slate-300 align-top"
>
{{ joinList(brand.stayDurations || brand.stay_durations) }}
</td>
<td class="px-6 py-4 text-right align-top">
<div class="flex justify-end gap-3">
<button
class="text-blue-600 hover:text-blue-800 font-medium text-sm"
@click="openEditModal(brand)"
>
Editar
</button>
<button
class="text-red-500 hover:text-red-700 font-medium text-sm"
@click="deleteBrand(brand.id)"
>
Excluir
</button>
</div>
</td>
</tr>
<tr v-if="brands.length === 0">
<td
colspan="4"
class="px-6 py-12 text-center text-slate-500 border-t border-slate-200 dark:border-slate-700"
>
<div class="flex flex-col items-center gap-2">
<i
class="i-lucide-building-2 text-4xl text-slate-300 mb-2"
/>
<p
class="text-base font-medium text-slate-900 dark:text-slate-100"
>
Nenhuma marca cadastrada
</p>
<p class="text-sm">
Clique no botão acima para adicionar a primeira marca.
</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<BrandModal
:show="showModal"
:brand="selectedBrand"
@close="showModal = false"
@save="handleSave"
/>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More