first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions
@@ -0,0 +1,637 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import InputNumber from 'primevue/inputnumber'
import Checkbox from 'primevue/checkbox'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import Popover from 'primevue/popover'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Avatar from 'primevue/avatar'
import AvatarGroup from 'primevue/avatargroup'
import Divider from 'primevue/divider'
import Badge from 'primevue/badge'
const billingInterval = ref('month') // 'month' | 'year'
function priceCentsFor (p, interval) {
return interval === 'year' ? p.yearly_cents : p.monthly_cents
}
// busca com FloatLabel (padrão)
import FloatLabel from 'primevue/floatlabel'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const rows = ref([])
const q = ref('')
const showDlg = ref(false)
const saving = ref(false)
const showBulletDlg = ref(false)
const bulletSaving = ref(false)
const bulletIsEdit = ref(false)
const selected = ref(null)
const form = ref({
plan_id: null,
public_name: '',
public_description: '',
badge: '',
is_featured: false,
is_visible: true,
sort_order: 0
})
const bullets = ref([])
const bulletForm = ref({
id: null,
text: '',
sort_order: 0,
highlight: false
})
/* popover bullets (na tabela) */
const bulletsPop = ref(null)
const popPlanTitle = ref('')
const popBullets = ref([])
function openBulletsPopover (event, row) {
popPlanTitle.value = row.public_name || row.plan_name || row.plan_key || 'Benefícios'
popBullets.value = Array.isArray(row.bullets) ? row.bullets : []
bulletsPop.value?.toggle(event)
}
function formatBRLFromCents (cents) {
if (cents == null) return '—'
const v = Number(cents) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
const filteredRows = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return rows.value
return rows.value.filter(r => {
const a = String(r.public_name || '').toLowerCase()
const b = String(r.plan_key || '').toLowerCase()
const c = String(r.plan_name || '').toLowerCase()
return a.includes(term) || b.includes(term) || c.includes(term)
})
})
const previewPlans = computed(() => {
// preview: só visíveis, ordenados. Featured primeiro dentro da mesma ordem.
const list = (rows.value || [])
.filter(r => r.is_visible !== false)
.slice()
.sort((a, b) => {
const ao = Number(a.sort_order ?? 0)
const bo = Number(b.sort_order ?? 0)
if (ao !== bo) return ao - bo
const af = a.is_featured ? 0 : 1
const bf = b.is_featured ? 0 : 1
if (af !== bf) return af - bf
return String(a.plan_key || '').localeCompare(String(b.plan_key || ''))
})
return list
})
async function fetchAll () {
loading.value = true
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select('*')
if (error) throw error
rows.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
loading.value = false
}
}
async function openEdit (row) {
selected.value = row
form.value = {
plan_id: row.plan_id,
public_name: row.public_name || row.plan_name || row.plan_key || '',
public_description: row.public_description || '',
badge: row.badge || '',
is_featured: !!row.is_featured,
is_visible: row.is_visible !== false,
sort_order: Number(row.sort_order ?? 0)
}
await fetchBullets(row.plan_id)
showDlg.value = true
}
async function fetchBullets (planId) {
const { data, error } = await supabase
.from('plan_public_bullets')
.select('*')
.eq('plan_id', planId)
.order('sort_order', { ascending: true })
.order('created_at', { ascending: true })
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
bullets.value = []
return
}
bullets.value = data || []
}
function validate () {
const name = String(form.value.public_name || '').trim()
if (!name) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome público do plano.', life: 3000 })
return false
}
form.value.public_name = name
form.value.public_description = String(form.value.public_description || '').trim()
form.value.badge = String(form.value.badge || '').trim() || null
form.value.sort_order = Number(form.value.sort_order ?? 0)
return true
}
async function save () {
if (!validate()) return
saving.value = true
try {
const payload = { ...form.value }
const { error } = await supabase
.from('plan_public')
.upsert(payload, { onConflict: 'plan_id' })
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Vitrine atualizada.', life: 2500 })
showDlg.value = false
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
saving.value = false
}
}
/* ---------- bullets (CRUD no dialog) ---------- */
function openBulletCreate () {
bulletIsEdit.value = false
bulletForm.value = { id: null, text: '', sort_order: (bullets.value?.length || 0) + 1, highlight: false }
showBulletDlg.value = true
}
function openBulletEdit (row) {
bulletIsEdit.value = true
bulletForm.value = {
id: row.id,
text: row.text ?? '',
sort_order: Number(row.sort_order ?? 0),
highlight: !!row.highlight
}
showBulletDlg.value = true
}
function validateBullet () {
const t = String(bulletForm.value.text || '').trim()
if (!t) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o texto do benefício.', life: 3000 })
return false
}
bulletForm.value.text = t
bulletForm.value.sort_order = Number(bulletForm.value.sort_order ?? 0)
return true
}
async function saveBullet () {
if (!selected.value?.plan_id) return
if (!validateBullet()) return
bulletSaving.value = true
try {
const payload = {
plan_id: selected.value.plan_id,
text: bulletForm.value.text,
sort_order: bulletForm.value.sort_order,
highlight: !!bulletForm.value.highlight
}
if (bulletIsEdit.value) {
const { error } = await supabase
.from('plan_public_bullets')
.update(payload)
.eq('id', bulletForm.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Benefício atualizado.', life: 2200 })
} else {
const { error } = await supabase
.from('plan_public_bullets')
.insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Benefício adicionado.', life: 2200 })
}
showBulletDlg.value = false
await fetchBullets(selected.value.plan_id)
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
bulletSaving.value = false
}
}
function askDeleteBullet (row) {
confirm.require({
message: 'Excluir este benefício?',
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doDeleteBullet(row)
})
}
async function doDeleteBullet (row) {
const { error } = await supabase
.from('plan_public_bullets')
.delete()
.eq('id', row.id)
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
return
}
toast.add({ severity: 'success', summary: 'Ok', detail: 'Benefício removido.', life: 2200 })
await fetchBullets(selected.value.plan_id)
await fetchAll()
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<ConfirmDialog />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Vitrine de Planos</div>
<small class="text-color-secondary mt-1">
Configure como os planos aparecem na página pública (nome, descrição, badge, ordem e benefícios).
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2 w-full md:w-auto">
<div class="w-full md:w-80">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="plans_public_search" class="w-full pr-10" variant="filled" />
</IconField>
<label for="plans_public_search">Buscar plano</label>
</FloatLabel>
</div>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
</div>
</template>
</Toolbar>
<!-- Popover global (reutilizado) -->
<Popover ref="bulletsPop">
<div class="w-[340px] max-w-[80vw]">
<div class="text-sm font-semibold mb-2">{{ popPlanTitle }}</div>
<div v-if="!popBullets?.length" class="text-sm text-color-secondary">
Nenhum benefício configurado.
</div>
<ul v-else class="m-0 pl-4 space-y-2">
<li v-for="b in popBullets" :key="b.id" class="text-sm leading-snug">
<span :class="b.highlight ? 'font-semibold' : ''">
{{ b.text }}
</span>
<small v-if="b.highlight" class="ml-2 text-color-secondary">(destaque)</small>
</li>
</ul>
</div>
</Popover>
<DataTable :value="filteredRows" dataKey="plan_id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Plano" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-semibold">{{ data.public_name || data.plan_name || data.plan_key }}</span>
<small class="text-color-secondary">
{{ data.plan_key }} {{ data.plan_name || '—' }}
</small>
</div>
</template>
</Column>
<Column header="Mensal" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header="Anual" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column field="badge" header="Badge" style="min-width: 12rem" />
<Column header="Visível" style="width: 8rem">
<template #body="{ data }">
<span>{{ data.is_visible ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Destaque" style="width: 9rem">
<template #body="{ data }">
<span>{{ data.is_featured ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column field="sort_order" header="Ordem" style="width: 8rem" />
<Column header="Ações" style="width: 14rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<!-- Popover bullets: ícone + quantidade -->
<Button
severity="secondary"
outlined
size="small"
@click="(e) => openBulletsPopover(e, data)"
>
<i class="pi pi-list mr-2" />
<span class="font-medium">{{ data.bullets?.length || 0 }}</span>
</Button>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="openEdit(data)" />
</div>
</template>
</Column>
</DataTable>
<!-- PREVIEW PÚBLICO (estilo PrimeBlocks) -->
<div class="mt-10">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 md:p-10 overflow-hidden">
<!-- topo "happy customers" + título -->
<div class="flex flex-col items-center text-center">
<div class="flex items-center gap-3 mb-4">
<AvatarGroup>
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png" shape="circle" />
</AvatarGroup>
<Divider layout="vertical" />
<span class="text-sm text-color-secondary font-medium">Happy Customers</span>
</div>
<h2 class="text-3xl md:text-5xl font-semibold leading-tight">
Get a plan and<br />
increase your efficiency
</h2>
<p class="text-color-secondary mt-3 max-w-2xl">
Optimize your workflow and boost productivity by choosing the right plan tailored to your business needs.
</p>
<!-- Toggle Monthly / Yearly -->
<div class="mt-6 inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
<Button
label="Mensal"
size="small"
:severity="billingInterval === 'month' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'month'"
@click="billingInterval = 'month'"
/>
<Button
label="Anual"
size="small"
:severity="billingInterval === 'year' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'year'"
class="ml-1"
@click="billingInterval = 'year'"
/>
</div>
</div>
<!-- cards -->
<div class="mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
<div
v-for="p in previewPlans"
:key="p.plan_id"
:class="[
'relative rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
'shadow-sm transition-transform',
p.is_featured ? 'md:-translate-y-2 md:scale-[1.02] ring-1 ring-emerald-500/25' : ''
]"
>
<!-- franja/topo decorativo (bem leve) -->
<div class="h-3 w-full opacity-40 bg-[var(--surface-100)]" />
<div class="p-6">
<!-- badge do plano -->
<div class="flex items-center justify-between gap-3">
<Badge
:value="p.badge || (p.is_featured ? 'Popular' : '')"
:severity="p.is_featured ? 'success' : 'secondary'"
v-if="p.badge || p.is_featured"
/>
<span class="text-xs text-color-secondary">{{ p.plan_key }}</span>
</div>
<!-- preço -->
<div class="mt-4 text-4xl font-semibold leading-none">
{{ formatBRLFromCents(priceCentsFor(p, billingInterval)) }}
</div>
<p class="text-color-secondary mt-3 min-h-[44px]">
{{ p.public_description || '—' }}
</p>
<Button
class="mt-5 w-full"
:label="p.is_featured ? 'Começar agora!' : 'Selecionar este'"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
/>
<!-- divisória pontilhada conceitual -->
<div class="mt-6">
<Divider type="dashed" />
</div>
<!-- bullets -->
<ul v-if="p.bullets?.length" class="mt-4 space-y-2">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-sm"></i>
<span :class="['text-sm leading-snug', b.highlight ? 'font-semibold' : '']">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="mt-4 text-sm text-color-secondary">
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Dialog principal -->
<Dialog v-model:visible="showDlg" modal header="Editar vitrine" :style="{ width: '820px' }">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Nome público</label>
<InputText v-model="form.public_name" class="w-full" placeholder="ex.: Profissional" />
</div>
<div>
<label class="block mb-2">Descrição pública</label>
<Textarea
v-model="form.public_description"
class="w-full"
:autoResize="true"
rows="3"
placeholder="Uma frase curta e clara..."
/>
</div>
<div>
<label class="block mb-2">Badge</label>
<InputText v-model="form.badge" class="w-full" placeholder="ex.: Mais popular (opcional)" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block mb-2">Ordem</label>
<InputNumber v-model="form.sort_order" class="w-full" inputClass="w-full" />
</div>
<div class="flex flex-col gap-3 pt-2">
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_visible" :binary="true" />
<label>Visível no público</label>
</div>
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_featured" :binary="true" />
<label>Destaque</label>
</div>
</div>
</div>
</div>
<!-- bullets -->
<div>
<div class="flex items-center justify-between mb-3">
<div class="font-semibold">Benefícios (bullets)</div>
<Button label="Adicionar" icon="pi pi-plus" size="small" @click="openBulletCreate" />
</div>
<DataTable :value="bullets" dataKey="id" stripedRows responsiveLayout="scroll">
<Column field="text" header="Texto" />
<Column field="sort_order" header="Ordem" style="width: 7rem" />
<Column header="Destaque" style="width: 8rem">
<template #body="{ data }">
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Ações" style="width: 9rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="openBulletEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="askDeleteBullet(data)" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
<!-- Dialog bullet -->
<Dialog v-model:visible="showBulletDlg" modal :header="bulletIsEdit ? 'Editar benefício' : 'Novo benefício'" :style="{ width: '560px' }">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Texto</label>
<Textarea
v-model="bulletForm.text"
class="w-full"
:autoResize="true"
rows="3"
placeholder="ex.: Agendamento online com página pública"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block mb-2">Ordem</label>
<InputNumber v-model="bulletForm.sort_order" class="w-full" inputClass="w-full" />
</div>
<div class="flex items-center gap-2 pt-7">
<Checkbox v-model="bulletForm.highlight" :binary="true" />
<label>Destaque</label>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showBulletDlg = false" />
<Button :label="bulletIsEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="bulletSaving" @click="saveBullet" />
</template>
</Dialog>
</div>
</template>