feat: add brands frontend (sidebar, index page, create/edit dialogs, translations)

This commit is contained in:
Paulo Castellano 2026-04-14 19:22:33 -03:00
parent e876ba3839
commit 3a98de0482
13 changed files with 398 additions and 6 deletions

View file

@ -1,6 +1,36 @@
<?php
return [
'new_brand' => 'New Brand',
'no_brands_yet' => 'No brands yet',
'no_brands_description' => 'Create brands to organize your social accounts by client or project',
'accounts_count' => ':count accounts',
'create' => [
'title' => 'Create Brand',
'description' => 'Give your brand a name to group social accounts',
'name' => 'Brand Name',
'name_placeholder' => 'e.g. Acme Corp, Personal',
'submit' => 'Create Brand',
'submitting' => 'Creating...',
],
'edit' => [
'title' => 'Edit Brand',
'description' => 'Update the name of this brand',
'name' => 'Brand Name',
'name_placeholder' => 'e.g. Acme Corp, Personal',
'submit' => 'Save Changes',
'submitting' => 'Saving...',
],
'delete' => [
'title' => 'Delete Brand',
'description' => 'Are you sure you want to delete this brand? Social accounts will be unassigned but not deleted.',
'confirm' => 'Delete',
'cancel' => 'Cancel',
],
'flash' => [
'created' => 'Brand created successfully!',
'updated' => 'Brand updated successfully!',

View file

@ -38,6 +38,7 @@
'config' => [
'connections' => 'Connections',
'brands' => 'Brands',
'hashtags' => 'Hashtags',
'labels' => 'Labels',
'settings' => 'Settings',

View file

@ -1,9 +1,39 @@
<?php
return [
'new_brand' => 'Nueva Marca',
'no_brands_yet' => 'No hay marcas todavía',
'no_brands_description' => 'Crea marcas para organizar tus cuentas de redes sociales por cliente o proyecto',
'accounts_count' => ':count cuentas',
'create' => [
'title' => 'Crear Marca',
'description' => 'Dale un nombre a tu marca para agrupar cuentas de redes sociales',
'name' => 'Nombre de la Marca',
'name_placeholder' => 'ej. Acme Corp, Personal',
'submit' => 'Crear Marca',
'submitting' => 'Creando...',
],
'edit' => [
'title' => 'Editar Marca',
'description' => 'Actualiza el nombre de esta marca',
'name' => 'Nombre de la Marca',
'name_placeholder' => 'ej. Acme Corp, Personal',
'submit' => 'Guardar Cambios',
'submitting' => 'Guardando...',
],
'delete' => [
'title' => 'Eliminar Marca',
'description' => '¿Estás seguro de que deseas eliminar esta marca? Las cuentas de redes sociales se desasignarán pero no se eliminarán.',
'confirm' => 'Eliminar',
'cancel' => 'Cancelar',
],
'flash' => [
'created' => 'Marca creada con éxito!',
'updated' => 'Marca actualizada con éxito!',
'deleted' => 'Marca eliminada con éxito!',
'created' => '¡Marca creada con éxito!',
'updated' => '¡Marca actualizada con éxito!',
'deleted' => '¡Marca eliminada con éxito!',
],
];

View file

@ -38,6 +38,7 @@
'config' => [
'connections' => 'Conexiones',
'brands' => 'Marcas',
'hashtags' => 'Hashtags',
'labels' => 'Etiquetas',
'settings' => 'Configuración',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,36 @@
<?php
return [
'new_brand' => 'Nova Marca',
'no_brands_yet' => 'Nenhuma marca ainda',
'no_brands_description' => 'Crie marcas para organizar suas contas de redes sociais por cliente ou projeto',
'accounts_count' => ':count contas',
'create' => [
'title' => 'Criar Marca',
'description' => 'Dê um nome à sua marca para agrupar contas de redes sociais',
'name' => 'Nome da Marca',
'name_placeholder' => 'ex. Acme Corp, Pessoal',
'submit' => 'Criar Marca',
'submitting' => 'Criando...',
],
'edit' => [
'title' => 'Editar Marca',
'description' => 'Atualize o nome desta marca',
'name' => 'Nome da Marca',
'name_placeholder' => 'ex. Acme Corp, Pessoal',
'submit' => 'Salvar Alterações',
'submitting' => 'Salvando...',
],
'delete' => [
'title' => 'Excluir Marca',
'description' => 'Tem certeza de que deseja excluir esta marca? As contas de redes sociais serão desvinculadas mas não excluídas.',
'confirm' => 'Excluir',
'cancel' => 'Cancelar',
],
'flash' => [
'created' => 'Marca criada com sucesso!',
'updated' => 'Marca atualizada com sucesso!',

View file

@ -38,6 +38,7 @@
'config' => [
'connections' => 'Conexões',
'brands' => 'Marcas',
'hashtags' => 'Hashtags',
'labels' => 'Etiquetas',
'settings' => 'Configurações',

View file

@ -8,6 +8,7 @@ import {
IconClock,
IconFileCheck,
IconFileText,
IconBuildingStore,
IconHash,
IconLifebuoy,
IconMessageCircle,
@ -44,6 +45,7 @@ import {
useSidebar,
} from '@/components/ui/sidebar';
import { accounts, analytics, calendar } from '@/routes/app';
import { index as brands } from '@/routes/app/brands';
import { index as hashtags } from '@/routes/app/hashtags';
import { index as labels } from '@/routes/app/labels';
import { edit as editProfile } from '@/routes/app/profile';
@ -109,6 +111,11 @@ const configNavItems = computed(() => {
href: accounts.url(),
icon: IconAffiliate,
},
{
title: trans('sidebar.config.brands'),
href: brands.url(),
icon: IconBuildingStore,
},
{
title: trans('sidebar.config.hashtags'),
href: hashtags.url(),

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { store as brandsStore } from '@/routes/app/brands';
const open = defineModel<boolean>('open', { default: false });
const form = useForm({
name: '',
});
const submit = () => {
form.post(brandsStore.url(), {
onSuccess: () => {
open.value = false;
form.reset();
},
});
};
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset();
form.clearErrors();
}
open.value = value;
};
</script>
<template>
<Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('brands.create.title') }}</DialogTitle>
<DialogDescription>
{{ $t('brands.create.description') }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="create-brand-name">{{ $t('brands.create.name') }}</Label>
<Input
id="create-brand-name"
v-model="form.name"
:placeholder="trans('brands.create.name_placeholder')"
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? $t('brands.create.submitting') : $t('brands.create.submit') }}
</Button>
<Button type="button" variant="secondary" @click="open = false">
{{ $t('common.cancel') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,85 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { watch } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { update as brandsUpdate } from '@/routes/app/brands';
interface Brand {
id: string;
name: string;
}
const props = defineProps<{
brand: Brand | null;
}>();
const open = defineModel<boolean>('open', { default: false });
const form = useForm({
name: '',
});
watch(() => props.brand, (brand) => {
if (brand) {
form.name = brand.name;
form.clearErrors();
}
}, { immediate: true });
const submit = () => {
if (!props.brand) return;
form.put(brandsUpdate.url(props.brand.id), {
onSuccess: () => {
open.value = false;
},
});
};
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('brands.edit.title') }}</DialogTitle>
<DialogDescription>
{{ $t('brands.edit.description') }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="edit-brand-name">{{ $t('brands.edit.name') }}</Label>
<Input
id="edit-brand-name"
v-model="form.name"
:placeholder="trans('brands.edit.name_placeholder')"
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? $t('brands.edit.submitting') : $t('brands.edit.submit') }}
</Button>
<Button type="button" variant="secondary" @click="open = false">
{{ $t('common.cancel') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,131 @@
<script setup lang="ts">
import { Head, InfiniteScroll } from '@inertiajs/vue3';
import { IconBuildingStore, IconPencil, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import CreateDialog from '@/components/brands/CreateDialog.vue';
import EditDialog from '@/components/brands/EditDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as brandsIndex, destroy as brandsDestroy } from '@/routes/app/brands';
import { type BreadcrumbItemType } from '@/types';
interface Brand {
id: string;
name: string;
social_accounts_count: number;
}
interface ScrollBrands {
data: Brand[];
meta: {
hasNextPage: boolean;
};
}
interface Props {
brands: ScrollBrands;
canCreate: boolean;
}
const props = defineProps<Props>();
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false);
const editingBrand = ref<Brand | null>(null);
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('sidebar.config.brands'), href: brandsIndex.url() },
]);
const openEditDialog = (brand: Brand) => {
editingBrand.value = brand;
isEditDialogOpen.value = true;
};
const handleDelete = (brandId: string) => {
deleteModal.value?.open({
url: brandsDestroy.url(brandId),
});
};
</script>
<template>
<Head :title="$t('sidebar.config.brands')" />
<AppLayout :breadcrumbs="breadcrumbs">
<template #header-right>
<Button :disabled="!canCreate" @click="isCreateDialogOpen = true">
{{ $t('brands.new_brand') }}
</Button>
</template>
<div class="flex flex-col gap-6 p-6">
<EmptyState
v-if="brands.data.length === 0"
:icon="IconBuildingStore"
:title="$t('brands.no_brands_yet')"
:description="$t('brands.no_brands_description')"
/>
<div v-else>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="brand in brands.data" :key="brand.id">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<CardTitle class="text-lg">{{ brand.name }}</CardTitle>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(brand)">
<IconPencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
@click="handleDelete(brand.id)">
<IconTrash class="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground">
{{ $t('brands.accounts_count', { count: String(brand.social_accounts_count) }) }}
</p>
</CardContent>
</Card>
</div>
<InfiniteScroll data="brands" #default="{ loading }">
<div v-if="loading" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-4">
<Card v-for="i in 3" :key="i">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<Skeleton class="h-6 w-32" />
<div class="flex gap-1">
<Skeleton class="h-8 w-8" />
<Skeleton class="h-8 w-8" />
</div>
</div>
</CardHeader>
<CardContent>
<Skeleton class="h-4 w-24" />
</CardContent>
</Card>
</div>
</InfiniteScroll>
</div>
</div>
</AppLayout>
<CreateDialog v-model:open="isCreateDialogOpen" />
<EditDialog v-model:open="isEditDialogOpen" :brand="editingBrand" />
<ConfirmDeleteModal ref="deleteModal" :title="$t('brands.delete.title')"
:description="$t('brands.delete.description')" :action="$t('brands.delete.confirm')"
:cancel="$t('brands.delete.cancel')" />
</template>