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:
parent
dacdc80fa0
commit
9b4bdc1ce9
11 changed files with 312 additions and 38 deletions
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
enum Type: string
|
||||
{
|
||||
case PostPublished = 'post_published';
|
||||
case PostFailed = 'post_failed';
|
||||
case PostPartiallyPublished = 'post_partially_published';
|
||||
case AccountDisconnected = 'account_disconnected';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
61
app/Mail/PostPublished.php
Normal file
61
app/Mail/PostPublished.php
Normal 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
|
|
@ -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',
|
||||
|
|
|
|||
50
maizzle/templates/post-published.html
Normal file
50
maizzle/templates/post-published.html
Normal 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 →
|
||||
</x-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<x-footer />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</x-main>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
125
resources/views/mail/post-published.blade.php
Normal file
125
resources/views/mail/post-published.blade.php
Normal 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 }}
|
||||
 ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
|
||||
</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">‍</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> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">View Post →</span>
|
||||
<!--[if mso]><i hidden style="mso-font-width: 150%"> ​</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>
|
||||
Loading…
Reference in a new issue