feat: post published notification, notification preferences prep

- Create PostPublished mail + maizzle template for success notifications
- Add PostPublished type to Notification enum
- Notify owner on publish success (email + in-app via SendNotification job)
- Rename AppSidebarHeader to AppHeader with left/right slots
- Fix layout: header fixed, content scrolls (flex h-screen pattern)
- Fix fullWidth pages (calendar, editor) to fill available height
- Fix NotificationBell icon size to match sidebar items
- Fix SocialAccountsGrid buttons to use shadcn Button ghost
- Add pending status i18n key for PostPlatform

All 745 tests passing.
This commit is contained in:
Paulo Castellano 2026-03-30 17:23:36 -03:00
parent dacdc80fa0
commit 9b4bdc1ce9
11 changed files with 312 additions and 38 deletions

View file

@ -6,6 +6,7 @@
enum Type: string
{
case PostPublished = 'post_published';
case PostFailed = 'post_failed';
case PostPartiallyPublished = 'post_partially_published';
case AccountDisconnected = 'account_disconnected';

View file

@ -10,6 +10,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\TokenExpiredException;
use App\Mail\PostPublished;
use App\Mail\PostPublishFailed;
use App\Models\Post;
use App\Models\PostPlatform;
@ -122,16 +123,45 @@ private function updatePostStatus(): void
if ($publishedCount === $total) {
$post->markAsPublished();
$this->notifySuccess($post);
} elseif ($publishedCount > 0) {
$post->markAsPartiallyPublished();
$this->notifyOwner($post);
$this->notifyFailure($post);
} else {
$post->markAsFailed();
$this->notifyOwner($post);
$this->notifyFailure($post);
}
}
private function notifyOwner(Post $post): void
private function notifySuccess(Post $post): void
{
$owner = $post->workspace->owner;
if (! $owner) {
return;
}
$publishedPlatforms = $post->postPlatforms()
->with('socialAccount')
->where('enabled', true)
->get()
->filter(fn ($pp) => $pp->status === PostPlatformStatus::Published)
->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')')
->implode(', ');
SendNotification::dispatch(
user: $owner,
workspaceId: $post->workspace_id,
type: Type::PostPublished,
channel: Channel::Both,
title: 'Post published successfully',
body: $publishedPlatforms,
data: ['post_id' => $post->id],
mailable: new PostPublished($post),
);
}
private function notifyFailure(Post $post): void
{
$owner = $post->workspace->owner;

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Enums\PostPlatform\Status;
use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PostPublished extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Post $post
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Your post was published in {$this->post->workspace->name}",
);
}
public function content(): Content
{
$publishedPlatforms = $this->post->postPlatforms()
->with('socialAccount')
->where('enabled', true)
->get()
->filter(fn ($pp) => $pp->status === Status::Published)
->map(fn ($pp) => [
'name' => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')',
'url' => $pp->platform_url,
])
->values()
->all();
return new Content(
view: 'mail.post-published',
with: [
'title' => 'Your post was published',
'previewText' => 'Your post has been published successfully.',
'body' => "Your post in the {$this->post->workspace->name} workspace has been published successfully.",
'publishedPlatforms' => $publishedPlatforms,
'url' => route('app.posts.edit', $this->post),
],
);
}
public function attachments(): array
{
return [];
}
}

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
<?php
return [
'workspaces' => 'Workspaces',
'workspaces' => 'Espaços de trabalho',
'select_workspace' => 'Selecionar workspace',
'create_workspace' => 'Criar workspace',
'create_post' => 'Novo post',

View file

@ -0,0 +1,50 @@
<x-main>
<div class="bg-zinc-50 sm:px-4 font-sans">
<table align="center">
<tr>
<td class="w-[552px] max-w-full">
<x-header />
<table class="w-full">
<tr>
<td class="p-12 sm:px-6 text-base text-zinc-700 bg-white rounded shadow-sm">
<h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
@{{ $title }}
</h1>
<p class="m-0 leading-6">
@{{ $body }}
</p>
<div class="mt-4 p-4 bg-green-50 rounded">
<p class="m-0 text-sm font-semibold text-green-900">
Published on:
</p>
<ul class="m-0 mt-2 pl-5 leading-6 text-sm">
@foreach($publishedPlatforms as $platform)
<li>
<strong>@{{ $platform['name'] }}</strong>
@if($platform['url'])
<a href="@{{ $platform['url'] }}" target="_blank" class="text-green-700 underline">View post</a>
@endif
</li>
@endforeach
</ul>
</div>
<x-spacer height="24px" />
<div class="flex items-center justify-center">
<x-button href="@{{ $url }}">
View Post &rarr;
</x-button>
</div>
</td>
</tr>
</table>
<x-footer />
</td>
</tr>
</table>
</div>
</x-main>

View file

@ -15,13 +15,15 @@ withDefaults(
<template>
<header
class="flex h-16 shrink-0 items-center justify-between gap-2 border-b border-border px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
class="flex h-16 shrink-0 items-center justify-between gap-2 border-b border-border bg-background px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" />
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</template>
<slot name="left">
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</template>
</slot>
</div>
<slot name="right" />
</header>
</template>
</template>

View file

@ -118,16 +118,14 @@ onMounted(() => {
<template>
<SidebarMenuButton :tooltip="$t('sidebar.notifications')" @click="openDialog">
<div class="relative">
<IconBell />
<span
v-if="unreadCount > 0"
class="absolute -top-1 -right-1 flex size-3.5 items-center justify-center rounded-full bg-destructive text-[8px] font-bold text-destructive-foreground"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<IconBell />
<span>{{ $t('sidebar.notifications') }}</span>
<span
v-if="unreadCount > 0"
class="ml-auto flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</SidebarMenuButton>
<Dialog v-model:open="dialogOpen">

View file

@ -202,10 +202,9 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<TooltipProvider v-if="showReconnect && isDisconnected(platform.account)">
<Tooltip>
<TooltipTrigger as-child>
<button @click="openOAuthPopup(platform.value)"
class="p-2 text-amber-600 hover:text-amber-700 transition-colors">
<IconRefresh class="h-4 w-4" />
</button>
<Button variant="ghost" size="icon" class="size-8 text-amber-600 hover:text-amber-700" @click="openOAuthPopup(platform.value)">
<IconRefresh class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ trans('accounts.reconnect_account') }}</p>
@ -216,10 +215,11 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
v-if="showViewProfile && getProfileUrl(platform.value, platform.account.username, platform.account.platform_user_id)">
<Tooltip>
<TooltipTrigger as-child>
<a :href="getProfileUrl(platform.value, platform.account.username, platform.account.platform_user_id)!" target="_blank"
class="p-2 text-muted-foreground hover:text-foreground transition-colors">
<IconExternalLink class="h-4 w-4" />
</a>
<Button variant="ghost" size="icon" class="size-8" as-child>
<a :href="getProfileUrl(platform.value, platform.account.username, platform.account.platform_user_id)!" target="_blank">
<IconExternalLink class="size-4" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ trans('accounts.view_profile') }}</p>
@ -229,10 +229,9 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<TooltipProvider v-if="showDisconnect">
<Tooltip>
<TooltipTrigger as-child>
<button @click="emit('disconnect', platform.account.id)"
class="p-2 text-muted-foreground hover:text-red-600 transition-colors">
<IconTrash class="h-4 w-4" />
</button>
<Button variant="ghost" size="icon" class="size-8" @click="emit('disconnect', platform.account.id)">
<IconTrash class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ trans('accounts.disconnect') }}</p>

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import AppHeader from '@/components/AppHeader.vue';
import AppSidebar from '@/components/AppSidebar.vue';
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
import Toast from '@/components/Toast.vue';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import type { BreadcrumbItem } from '@/types';
@ -24,20 +24,28 @@ withDefaults(defineProps<Props>(), {
<template>
<SidebarProvider :default-open="isOpen">
<AppSidebar />
<SidebarInset class="overflow-x-hidden">
<AppSidebarHeader :breadcrumbs="breadcrumbs">
<SidebarInset class="flex h-screen flex-col overflow-hidden">
<AppHeader :breadcrumbs="breadcrumbs">
<template v-if="$slots['header-right']" #right>
<slot name="header-right" />
</template>
</AppSidebarHeader>
</AppHeader>
<div
:class="
fullWidth
? 'flex min-h-0 flex-1 flex-col'
: 'mx-auto w-full max-w-7xl'
? 'flex min-h-0 flex-1 flex-col overflow-hidden'
: 'flex-1 overflow-y-auto'
"
>
<slot />
<div
:class="
fullWidth
? 'flex min-h-0 flex-1 flex-col'
: 'mx-auto w-full max-w-7xl'
"
>
<slot />
</div>
</div>
</SidebarInset>
</SidebarProvider>

View file

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
</style>
<![endif]-->
@if(isset($title))
<title>{{ $title }}</title>
@endif
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" media="screen">
<style>
.hover-i-text-decoration-underline:hover {
text-decoration: underline !important
}
@media (max-width: 600px) {
.sm-my-8 {
margin-top: 32px !important;
margin-bottom: 32px !important
}
.sm-px-4 {
padding-left: 16px !important;
padding-right: 16px !important
}
.sm-px-6 {
padding-left: 24px !important;
padding-right: 24px !important
}
.sm-leading-8 {
line-height: 32px !important
}
}
</style>
</head>
<body style="margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
@if(isset($previewText))
<div style="display: none">
{{ $previewText }}
&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;
</div>
@endif
<div role="article" aria-roledescription="email" aria-label="{{ $title }}" lang="en">
<div class="sm-px-4" style="background-color: #fafafa; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
<table align="center" cellpadding="0" cellspacing="0" role="none">
<tr>
<td style="width: 552px; max-width: 100%">
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
<a href="https://trypost.it" target="_blank">
<img src="{{ email_asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
</a>
</div>
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
<tr>
<td class="sm-px-6" style="border-radius: 4px; background-color: #fffffe; padding: 48px; font-size: 16px; color: #3f3f46; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
<h1 class="sm-leading-8" style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #000001">
{{ $title }}
</h1>
<p style="margin: 0; line-height: 24px">
{{ $body }}
</p>
<div style="margin-top: 16px; border-radius: 4px; background-color: #f0fdf4; padding: 16px">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #14532d">
Published on:
</p>
<ul style="margin: 8px 0 0; padding-left: 20px; font-size: 14px; line-height: 24px">
@foreach($publishedPlatforms as $platform)
<li>
<strong>{{ $platform['name'] }}</strong>
@if($platform['url'])
<a href="{{ $platform['url'] }}" target="_blank" style="color: #15803d; text-decoration: underline">View post</a>
@endif
</li>
@endforeach
</ul>
</div>
<div role="separator" style="line-height: 24px">&zwj;</div>
<div style="display: flex; align-items: center; justify-content: center">
<div>
<a href="{{ $url }}" style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 8px; background-color: #262626; color: #ffffff">
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden>&emsp;</i><![endif]-->
<span style="mso-text-raise: 16px">View Post &rarr;</span>
<!--[if mso]><i hidden style="mso-font-width: 150%">&emsp;&#8203;</i><![endif]-->
</a>
</div>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 24px; text-align: center; font-size: 12px; color: #52525b">
<p style="margin: 0 0 8px">
Open-source social media scheduling tool
</p>
@if(isset($unsubscribe_url))
<p style="margin: 8px 0 0">
<a href="{{ unsubscribe_url }}" target="_blank" class="hover-i-text-decoration-underline" style="color: #52525b; text-decoration: none">
Unsubscribe
</a>
</p>
@endif
</td>
</tr>
</table>
</div>
</div>
</body>
</html>