Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,163 +15,156 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const toast = useToast()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const isFetching = ref(false)
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const isFetching = ref(false);
|
||||
|
||||
const uid = ref(null)
|
||||
const currentSub = ref(null)
|
||||
const uid = ref(null);
|
||||
const currentSub = ref(null);
|
||||
|
||||
const plans = ref([]) // plans (therapist)
|
||||
const prices = ref([]) // plan_prices ativos do momento
|
||||
const plans = ref([]); // plans (therapist)
|
||||
const prices = ref([]); // plan_prices ativos do momento
|
||||
|
||||
const q = ref('')
|
||||
const q = ref('');
|
||||
|
||||
const billingInterval = ref('month') // 'month' | 'year'
|
||||
const billingInterval = ref('month'); // 'month' | 'year'
|
||||
const intervalOptions = [
|
||||
{ label: 'Mensal', value: 'month' },
|
||||
{ label: 'Anual', value: 'year' }
|
||||
]
|
||||
{ label: 'Mensal', value: 'month' },
|
||||
{ label: 'Anual', value: 'year' }
|
||||
];
|
||||
|
||||
const redirectTo = computed(() => route.query.redirectTo || '/therapist/meu-plano')
|
||||
const redirectTo = computed(() => route.query.redirectTo || '/therapist/meu-plano');
|
||||
|
||||
function money (currency, amountCents) {
|
||||
if (amountCents == null) return null
|
||||
const value = Number(amountCents) / 100
|
||||
try {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency || 'BRL' }).format(value)
|
||||
} catch (_) {
|
||||
return `${value.toFixed(2)} ${currency || ''}`.trim()
|
||||
}
|
||||
function money(currency, amountCents) {
|
||||
if (amountCents == null) return null;
|
||||
const value = Number(amountCents) / 100;
|
||||
try {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: currency || 'BRL' }).format(value);
|
||||
} catch (_) {
|
||||
return `${value.toFixed(2)} ${currency || ''}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function intervalLabel (i) {
|
||||
if (i === 'month') return 'mês'
|
||||
if (i === 'year') return 'ano'
|
||||
return i || '-'
|
||||
function intervalLabel(i) {
|
||||
if (i === 'month') return 'mês';
|
||||
if (i === 'year') return 'ano';
|
||||
return i || '-';
|
||||
}
|
||||
|
||||
function priceFor (planId, interval) {
|
||||
return (prices.value || []).find(p => String(p.plan_id) === String(planId) && String(p.interval) === String(interval)) || null
|
||||
function priceFor(planId, interval) {
|
||||
return (prices.value || []).find((p) => String(p.plan_id) === String(planId) && String(p.interval) === String(interval)) || null;
|
||||
}
|
||||
|
||||
function priceLabelForCard (planRow) {
|
||||
const pp = priceFor(planRow?.id, billingInterval.value)
|
||||
if (!pp) return '—'
|
||||
return `${money(pp.currency, pp.amount_cents)} / ${intervalLabel(billingInterval.value)}`
|
||||
function priceLabelForCard(planRow) {
|
||||
const pp = priceFor(planRow?.id, billingInterval.value);
|
||||
if (!pp) return '—';
|
||||
return `${money(pp.currency, pp.amount_cents)} / ${intervalLabel(billingInterval.value)}`;
|
||||
}
|
||||
|
||||
const filteredPlans = computed(() => {
|
||||
const term = String(q.value || '').trim().toLowerCase()
|
||||
if (!term) return plans.value || []
|
||||
return (plans.value || []).filter(p => {
|
||||
const a = String(p.key || '').toLowerCase()
|
||||
const b = String(p.name || '').toLowerCase()
|
||||
const c = String(p.description || '').toLowerCase()
|
||||
return a.includes(term) || b.includes(term) || c.includes(term)
|
||||
})
|
||||
})
|
||||
const term = String(q.value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!term) return plans.value || [];
|
||||
return (plans.value || []).filter((p) => {
|
||||
const a = String(p.key || '').toLowerCase();
|
||||
const b = String(p.name || '').toLowerCase();
|
||||
const c = String(p.description || '').toLowerCase();
|
||||
return a.includes(term) || b.includes(term) || c.includes(term);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadData () {
|
||||
if (isFetching.value) return
|
||||
isFetching.value = true
|
||||
loading.value = true
|
||||
async function loadData() {
|
||||
if (isFetching.value) return;
|
||||
isFetching.value = true;
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const { data: authData, error: authError } = await supabase.auth.getUser()
|
||||
if (authError) throw authError
|
||||
try {
|
||||
const { data: authData, error: authError } = await supabase.auth.getUser();
|
||||
if (authError) throw authError;
|
||||
|
||||
uid.value = authData?.user?.id
|
||||
if (!uid.value) throw new Error('Sessão não encontrada.')
|
||||
uid.value = authData?.user?.id;
|
||||
if (!uid.value) throw new Error('Sessão não encontrada.');
|
||||
|
||||
// assinatura pessoal atual (Modelo A): busca por user_id, prioriza status ativo
|
||||
const sRes = await supabase
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', uid.value)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
// assinatura pessoal atual (Modelo A): busca por user_id, prioriza status ativo
|
||||
const sRes = await supabase.from('subscriptions').select('*').eq('user_id', uid.value).order('created_at', { ascending: false }).limit(10);
|
||||
|
||||
if (sRes.error) throw sRes.error
|
||||
if (sRes.error) throw sRes.error;
|
||||
|
||||
const subList = sRes.data || []
|
||||
const subPriority = (st) => {
|
||||
const s = String(st || '').toLowerCase()
|
||||
if (s === 'active') return 1
|
||||
if (s === 'trialing') return 2
|
||||
if (s === 'past_due') return 3
|
||||
if (s === 'unpaid') return 4
|
||||
if (s === 'incomplete') return 5
|
||||
if (s === 'canceled' || s === 'cancelled') return 9
|
||||
return 8
|
||||
const subList = sRes.data || [];
|
||||
const subPriority = (st) => {
|
||||
const s = String(st || '').toLowerCase();
|
||||
if (s === 'active') return 1;
|
||||
if (s === 'trialing') return 2;
|
||||
if (s === 'past_due') return 3;
|
||||
if (s === 'unpaid') return 4;
|
||||
if (s === 'incomplete') return 5;
|
||||
if (s === 'canceled' || s === 'cancelled') return 9;
|
||||
return 8;
|
||||
};
|
||||
currentSub.value = subList.length
|
||||
? subList.slice().sort((a, b) => {
|
||||
const pa = subPriority(a?.status);
|
||||
const pb = subPriority(b?.status);
|
||||
if (pa !== pb) return pa - pb;
|
||||
return new Date(b?.created_at || 0) - new Date(a?.created_at || 0);
|
||||
})[0]
|
||||
: null;
|
||||
|
||||
// planos do terapeuta (target = therapist)
|
||||
const pRes = await supabase.from('plans').select('id, key, name, description, target, is_active').eq('target', 'therapist').order('created_at', { ascending: true });
|
||||
|
||||
if (pRes.error) throw pRes.error;
|
||||
plans.value = (pRes.data || []).filter((p) => p?.is_active !== false);
|
||||
|
||||
// preços ativos (janela de vigência)
|
||||
const nowIso = new Date().toISOString();
|
||||
const ppRes = await supabase
|
||||
.from('plan_prices')
|
||||
.select('plan_id, currency, interval, amount_cents, is_active, active_from, active_to')
|
||||
.eq('is_active', true)
|
||||
.lte('active_from', nowIso)
|
||||
.or(`active_to.is.null,active_to.gte.${nowIso}`)
|
||||
.order('active_from', { ascending: false });
|
||||
|
||||
if (ppRes.error) throw ppRes.error;
|
||||
prices.value = ppRes.data || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
isFetching.value = false;
|
||||
}
|
||||
currentSub.value = subList.length
|
||||
? subList.slice().sort((a, b) => {
|
||||
const pa = subPriority(a?.status)
|
||||
const pb = subPriority(b?.status)
|
||||
if (pa !== pb) return pa - pb
|
||||
return new Date(b?.created_at || 0) - new Date(a?.created_at || 0)
|
||||
})[0]
|
||||
: null
|
||||
|
||||
// planos do terapeuta (target = therapist)
|
||||
const pRes = await supabase
|
||||
.from('plans')
|
||||
.select('id, key, name, description, target, is_active')
|
||||
.eq('target', 'therapist')
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (pRes.error) throw pRes.error
|
||||
plans.value = (pRes.data || []).filter(p => p?.is_active !== false)
|
||||
|
||||
// preços ativos (janela de vigência)
|
||||
const nowIso = new Date().toISOString()
|
||||
const ppRes = await supabase
|
||||
.from('plan_prices')
|
||||
.select('plan_id, currency, interval, amount_cents, is_active, active_from, active_to')
|
||||
.eq('is_active', true)
|
||||
.lte('active_from', nowIso)
|
||||
.or(`active_to.is.null,active_to.gte.${nowIso}`)
|
||||
.order('active_from', { ascending: false })
|
||||
|
||||
if (ppRes.error) throw ppRes.error
|
||||
prices.value = ppRes.data || []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
isFetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function preflight (planRow, interval) {
|
||||
if (!uid.value) return { ok: false, msg: 'Sessão não encontrada.' }
|
||||
if (!planRow?.id) return { ok: false, msg: 'Plano inválido.' }
|
||||
if (!['month', 'year'].includes(String(interval))) return { ok: false, msg: 'Intervalo inválido.' }
|
||||
function preflight(planRow, interval) {
|
||||
if (!uid.value) return { ok: false, msg: 'Sessão não encontrada.' };
|
||||
if (!planRow?.id) return { ok: false, msg: 'Plano inválido.' };
|
||||
if (!['month', 'year'].includes(String(interval))) return { ok: false, msg: 'Intervalo inválido.' };
|
||||
|
||||
const pp = priceFor(planRow.id, interval)
|
||||
if (!pp) {
|
||||
return { ok: false, msg: `Este plano não tem preço ativo para ${intervalLabel(interval)}.` }
|
||||
}
|
||||
const pp = priceFor(planRow.id, interval);
|
||||
if (!pp) {
|
||||
return { ok: false, msg: `Este plano não tem preço ativo para ${intervalLabel(interval)}.` };
|
||||
}
|
||||
|
||||
// se já estiver nesse plano+intervalo, evita ação
|
||||
if (currentSub.value?.plan_id === planRow.id && String(currentSub.value?.interval) === String(interval)) {
|
||||
return { ok: false, msg: 'Você já está nesse plano/intervalo.' }
|
||||
}
|
||||
// se já estiver nesse plano+intervalo, evita ação
|
||||
if (currentSub.value?.plan_id === planRow.id && String(currentSub.value?.interval) === String(interval)) {
|
||||
return { ok: false, msg: 'Você já está nesse plano/intervalo.' };
|
||||
}
|
||||
|
||||
return { ok: true, msg: '' }
|
||||
return { ok: true, msg: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,301 +177,263 @@ function preflight (planRow, interval) {
|
||||
* e que interval pode ser alterado por update simples ou outro RPC.
|
||||
* - se você tiver um RPC específico para intervalo, só troca abaixo.
|
||||
*/
|
||||
async function choosePlan (planRow, interval) {
|
||||
const pf = preflight(planRow, interval)
|
||||
if (!pf.ok) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: pf.msg, life: 4200 })
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const nowIso = new Date().toISOString()
|
||||
|
||||
if (currentSub.value?.id) {
|
||||
// 1) troca plano via RPC (auditoria)
|
||||
const { error: e1 } = await supabase.rpc('change_subscription_plan', {
|
||||
p_subscription_id: currentSub.value.id,
|
||||
p_new_plan_id: planRow.id
|
||||
})
|
||||
if (e1) throw e1
|
||||
|
||||
// 2) atualiza intervalo (se o seu RPC já faz isso, pode remover)
|
||||
const { error: e2 } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
interval,
|
||||
updated_at: nowIso,
|
||||
cancel_at_period_end: false,
|
||||
status: 'active'
|
||||
})
|
||||
.eq('id', currentSub.value.id)
|
||||
if (e2) throw e2
|
||||
} else {
|
||||
// cria subscription pessoal
|
||||
const { data: ins, error: eIns } = await supabase
|
||||
.from('subscriptions')
|
||||
.insert({
|
||||
user_id: uid.value,
|
||||
tenant_id: null,
|
||||
plan_id: planRow.id,
|
||||
plan_key: planRow.key,
|
||||
interval,
|
||||
status: 'active',
|
||||
cancel_at_period_end: false,
|
||||
provider: 'manual',
|
||||
source: 'therapist_upgrade',
|
||||
started_at: nowIso,
|
||||
current_period_start: nowIso
|
||||
})
|
||||
.select('*')
|
||||
.maybeSingle()
|
||||
|
||||
if (eIns) throw eIns
|
||||
currentSub.value = ins || null
|
||||
async function choosePlan(planRow, interval) {
|
||||
const pf = preflight(planRow, interval);
|
||||
if (!pf.ok) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: pf.msg, life: 4200 });
|
||||
return;
|
||||
}
|
||||
|
||||
// (opcional) se sua regra de entitlements depende disso, você pode rebuildar aqui:
|
||||
// await supabase.rpc('rebuild_owner_entitlements', { p_owner_id: uid.value })
|
||||
saving.value = true;
|
||||
try {
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Plano atualizado',
|
||||
detail: 'Seu plano foi atualizado com sucesso.',
|
||||
life: 3200
|
||||
})
|
||||
if (currentSub.value?.id) {
|
||||
// 1) troca plano via RPC (auditoria)
|
||||
const { error: e1 } = await supabase.rpc('change_subscription_plan', {
|
||||
p_subscription_id: currentSub.value.id,
|
||||
p_new_plan_id: planRow.id
|
||||
});
|
||||
if (e1) throw e1;
|
||||
|
||||
// ✅ garante refletir estado real
|
||||
await loadData()
|
||||
// 2) atualiza intervalo (se o seu RPC já faz isso, pode remover)
|
||||
const { error: e2 } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
interval,
|
||||
updated_at: nowIso,
|
||||
cancel_at_period_end: false,
|
||||
status: 'active'
|
||||
})
|
||||
.eq('id', currentSub.value.id);
|
||||
if (e2) throw e2;
|
||||
} else {
|
||||
// cria subscription pessoal
|
||||
const { data: ins, error: eIns } = await supabase
|
||||
.from('subscriptions')
|
||||
.insert({
|
||||
user_id: uid.value,
|
||||
tenant_id: null,
|
||||
plan_id: planRow.id,
|
||||
plan_key: planRow.key,
|
||||
interval,
|
||||
status: 'active',
|
||||
cancel_at_period_end: false,
|
||||
provider: 'manual',
|
||||
source: 'therapist_upgrade',
|
||||
started_at: nowIso,
|
||||
current_period_start: nowIso
|
||||
})
|
||||
.select('*')
|
||||
.maybeSingle();
|
||||
|
||||
// redirect
|
||||
await router.push(String(redirectTo.value))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
if (eIns) throw eIns;
|
||||
currentSub.value = ins || null;
|
||||
}
|
||||
|
||||
// (opcional) se sua regra de entitlements depende disso, você pode rebuildar aqui:
|
||||
// await supabase.rpc('rebuild_owner_entitlements', { p_owner_id: uid.value })
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Plano atualizado',
|
||||
detail: 'Seu plano foi atualizado com sucesso.',
|
||||
life: 3200
|
||||
});
|
||||
|
||||
// ✅ garante refletir estado real
|
||||
await loadData();
|
||||
|
||||
// redirect
|
||||
await router.push(String(redirectTo.value));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack () {
|
||||
router.push(String(redirectTo.value))
|
||||
function goBack() {
|
||||
router.push(String(redirectTo.value));
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Sentinel -->
|
||||
<div class="h-px" />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div class="h-px" />
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
HERO sticky
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<!-- Blobs -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex flex-col gap-2.5">
|
||||
|
||||
<!-- Linha 1: brand + busca + ações -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-arrow-up-right text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden sm:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Upgrade do Terapeuta</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">Escolha seu plano pessoal (Modelo A)</div>
|
||||
</div>
|
||||
<section class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
|
||||
<!-- Blobs -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<!-- Busca desktop -->
|
||||
<div class="hidden md:flex flex-1 min-w-[180px] max-w-xs">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
|
||||
</IconField>
|
||||
<div class="relative z-[1] flex flex-col gap-2.5">
|
||||
<!-- Linha 1: brand + busca + ações -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-arrow-up-right text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden sm:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Upgrade do Terapeuta</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">Escolha seu plano pessoal (Modelo A)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busca desktop -->
|
||||
<div class="hidden md:flex flex-1 min-w-[180px] max-w-xs">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" :disabled="saving" title="Atualizar" @click="loadData" />
|
||||
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="goBack" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linha 2: busca mobile + seletor de intervalo -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Busca mobile -->
|
||||
<div class="flex md:hidden flex-1 min-w-[160px]">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<!-- Intervalo chips -->
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 hidden sm:inline">Preço:</span>
|
||||
<button
|
||||
v-for="opt in intervalOptions"
|
||||
:key="opt.value"
|
||||
class="inline-flex items-center gap-1.5 px-3.5 py-1 rounded-full text-[1rem] font-semibold border-[1.5px] cursor-pointer transition-all duration-150 select-none"
|
||||
:class="
|
||||
billingInterval === opt.value
|
||||
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
|
||||
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-[var(--text-color)]'
|
||||
"
|
||||
:disabled="loading || saving"
|
||||
@click="billingInterval = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Plano atual -->
|
||||
<Tag v-if="currentSub" :value="`Atual: ${currentSub.plan_key} · ${intervalLabel(currentSub.interval)}`" severity="success" />
|
||||
<Tag v-else value="Sem plano pessoal" severity="warning" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" :disabled="saving" title="Atualizar" @click="loadData" />
|
||||
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="goBack" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linha 2: busca mobile + seletor de intervalo -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Busca mobile -->
|
||||
<div class="flex md:hidden flex-1 min-w-[160px]">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<!-- Intervalo chips -->
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 hidden sm:inline">Preço:</span>
|
||||
<button
|
||||
v-for="opt in intervalOptions"
|
||||
:key="opt.value"
|
||||
class="inline-flex items-center gap-1.5 px-3.5 py-1 rounded-full text-[1rem] font-semibold border-[1.5px] cursor-pointer transition-all duration-150 select-none"
|
||||
:class="billingInterval === opt.value
|
||||
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
|
||||
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-[var(--text-color)]'"
|
||||
:disabled="loading || saving"
|
||||
@click="billingInterval = opt.value"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Plano atual -->
|
||||
<Tag
|
||||
v-if="currentSub"
|
||||
:value="`Atual: ${currentSub.plan_key} · ${intervalLabel(currentSub.interval)}`"
|
||||
severity="success"
|
||||
/>
|
||||
<Tag v-else value="Sem plano pessoal" severity="warning" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
QUICK-STATS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ filteredPlans.length }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Planos disponíveis</div>
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ filteredPlans.length }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Planos disponíveis</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ currentSub?.plan_key || '—' }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Plano atual</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ billingInterval === 'month' ? 'Mensal' : 'Anual' }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Exibição de preço</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ currentSub?.plan_key || '—' }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Plano atual</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ billingInterval === 'month' ? 'Mensal' : 'Anual' }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Exibição de preço</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
PLANOS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="px-3 md:px-4 pb-8">
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="h-4 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="h-3 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="h-8 w-1/3 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="!filteredPlans.length"
|
||||
class="flex flex-col items-center justify-center gap-4 rounded-md border-2 border-dashed border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] py-16 px-6 text-center"
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-box text-3xl opacity-30" />
|
||||
</div>
|
||||
<div class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
|
||||
<i class="pi pi-times text-[0.65rem] text-[var(--text-color-secondary)] opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhum plano encontrado</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Tente limpar o filtro de busca.</div>
|
||||
</div>
|
||||
<Button label="Limpar busca" icon="pi pi-filter-slash" severity="secondary" outlined class="rounded-full mt-1" @click="q = ''" />
|
||||
</div>
|
||||
|
||||
<!-- Grid de planos -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="p in filteredPlans"
|
||||
:key="p.id"
|
||||
class="rounded-md border bg-[var(--surface-card,#fff)] overflow-hidden flex flex-col transition-shadow duration-150 hover:shadow-[0_4px_18px_rgba(0,0,0,0.07)]"
|
||||
:class="currentSub?.plan_id === p.id
|
||||
? 'border-emerald-400/40 ring-1 ring-emerald-500/20'
|
||||
: 'border-[var(--surface-border,#e2e8f0)]'"
|
||||
>
|
||||
<!-- Cabeçalho do card -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-[0.9rem] text-[var(--text-color)] truncate">{{ p.name || p.key }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.key }}</div>
|
||||
</div>
|
||||
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" />
|
||||
</div>
|
||||
|
||||
<!-- Corpo do card -->
|
||||
<div class="p-4 flex flex-col gap-4 flex-1">
|
||||
<!-- Descrição -->
|
||||
<div v-if="p.description" class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.description }}</div>
|
||||
|
||||
<!-- Preço -->
|
||||
<div>
|
||||
<div class="text-[2rem] font-bold leading-none text-[var(--text-color)]">{{ priceLabelForCard(p) }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1 opacity-70">Alternar mensal/anual no topo para comparar.</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex flex-col gap-2 mt-auto">
|
||||
<Button
|
||||
:label="billingInterval === 'month' ? 'Escolher mensal' : 'Escolher anual'"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full w-full"
|
||||
:loading="saving"
|
||||
:disabled="loading || saving"
|
||||
@click="choosePlan(p, billingInterval)"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
label="Mensal"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full flex-1"
|
||||
:disabled="loading || saving"
|
||||
@click="choosePlan(p, 'month')"
|
||||
/>
|
||||
<Button
|
||||
label="Anual"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full flex-1"
|
||||
:disabled="loading || saving"
|
||||
@click="choosePlan(p, 'year')"
|
||||
/>
|
||||
<div class="px-3 md:px-4 pb-8">
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<div v-for="n in 3" :key="n" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="h-4 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="h-3 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="h-8 w-1/3 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status do preço -->
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">
|
||||
<span v-if="priceFor(p.id, billingInterval)">Preço ativo encontrado para {{ intervalLabel(billingInterval) }}.</span>
|
||||
<span v-else>Sem preço ativo para {{ intervalLabel(billingInterval) }}.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!filteredPlans.length" class="flex flex-col items-center justify-center gap-4 rounded-md border-2 border-dashed border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] py-16 px-6 text-center">
|
||||
<div class="relative">
|
||||
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-box text-3xl opacity-30" />
|
||||
</div>
|
||||
<div class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
|
||||
<i class="pi pi-times text-[0.65rem] text-[var(--text-color-secondary)] opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhum plano encontrado</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Tente limpar o filtro de busca.</div>
|
||||
</div>
|
||||
<Button label="Limpar busca" icon="pi pi-filter-slash" severity="secondary" outlined class="rounded-full mt-1" @click="q = ''" />
|
||||
</div>
|
||||
|
||||
<!-- Grid de planos -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="p in filteredPlans"
|
||||
:key="p.id"
|
||||
class="rounded-md border bg-[var(--surface-card,#fff)] overflow-hidden flex flex-col transition-shadow duration-150 hover:shadow-[0_4px_18px_rgba(0,0,0,0.07)]"
|
||||
:class="currentSub?.plan_id === p.id ? 'border-emerald-400/40 ring-1 ring-emerald-500/20' : 'border-[var(--surface-border,#e2e8f0)]'"
|
||||
>
|
||||
<!-- Cabeçalho do card -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-[0.9rem] text-[var(--text-color)] truncate">{{ p.name || p.key }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.key }}</div>
|
||||
</div>
|
||||
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" />
|
||||
</div>
|
||||
|
||||
<!-- Corpo do card -->
|
||||
<div class="p-4 flex flex-col gap-4 flex-1">
|
||||
<!-- Descrição -->
|
||||
<div v-if="p.description" class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.description }}</div>
|
||||
|
||||
<!-- Preço -->
|
||||
<div>
|
||||
<div class="text-[2rem] font-bold leading-none text-[var(--text-color)]">{{ priceLabelForCard(p) }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1 opacity-70">Alternar mensal/anual no topo para comparar.</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex flex-col gap-2 mt-auto">
|
||||
<Button :label="billingInterval === 'month' ? 'Escolher mensal' : 'Escolher anual'" icon="pi pi-check" class="rounded-full w-full" :loading="saving" :disabled="loading || saving" @click="choosePlan(p, billingInterval)" />
|
||||
<div class="flex gap-2">
|
||||
<Button label="Mensal" severity="secondary" outlined class="rounded-full flex-1" :disabled="loading || saving" @click="choosePlan(p, 'month')" />
|
||||
<Button label="Anual" severity="secondary" outlined class="rounded-full flex-1" :disabled="loading || saving" @click="choosePlan(p, 'year')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status do preço -->
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">
|
||||
<span v-if="priceFor(p.id, billingInterval)">Preço ativo encontrado para {{ intervalLabel(billingInterval) }}.</span>
|
||||
<span v-else>Sem preço ativo para {{ intervalLabel(billingInterval) }}.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user