refactor: notification preferences, header slots, calendar layout, UI polish
Notification preferences: - Create notification_preferences table (post_published, post_failed, account_disconnected booleans per user) - NotificationPreferenceController with firstOrCreate on first visit - SendNotification job respects email preferences before sending - Settings page with toggle switches, i18n in 3 languages - 8 new tests for preferences (controller + wantsEmailFor + job integration) Post published notification: - PostPublished mail + maizzle template - Notify owner on successful publish via SendNotification job - PostPublished type added to notification enum Header & Layout: - Rename AppSidebarHeader to AppHeader with left/center/right slots - showSidebarTrigger prop to hide sidebar toggle - Calendar: controls in header (left: nav, center: date, right: tabs + new post) - Fixed header with scrollable content (flex h-screen pattern) - fullWidth pages use overflow-y-auto (fixes month view scroll) UI improvements: - Action buttons moved to header-right: posts, hashtags, labels - Settings breadcrumbs: "Settings > Profile" pattern - Calendar: remove duplicate New Post button from day view - Remove size="sm" from Schedule/Publish buttons - Remove bg-background from header (inherits from SidebarInset) - Add Cancel button to labels and hashtags create/edit dialogs - Add common.cancel i18n key - Clean up orphaned Calendar breadcrumbs - Fix SocialAccountsGrid buttons to use shadcn Button ghost All 753 tests passing.
This commit is contained in:
parent
9b4bdc1ce9
commit
aa7ca8ae21
36 changed files with 540 additions and 113 deletions
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App\Settings;
|
||||
|
||||
use App\Http\Controllers\App\Controller;
|
||||
use App\Models\NotificationPreference;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class NotificationPreferenceController extends Controller
|
||||
{
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$preferences = NotificationPreference::firstOrCreate(
|
||||
['user_id' => $request->user()->id],
|
||||
[
|
||||
'post_published' => true,
|
||||
'post_failed' => true,
|
||||
'account_disconnected' => true,
|
||||
],
|
||||
);
|
||||
|
||||
return Inertia::render('settings/Notifications', [
|
||||
'preferences' => $preferences,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'post_published' => ['required', 'boolean'],
|
||||
'post_failed' => ['required', 'boolean'],
|
||||
'account_disconnected' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
NotificationPreference::updateOrCreate(
|
||||
['user_id' => $request->user()->id],
|
||||
$validated,
|
||||
);
|
||||
|
||||
session()->flash('flash.banner', __('settings.flash.notifications_updated'));
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
|
|
@ -51,8 +51,8 @@ public function handle(): void
|
|||
]);
|
||||
}
|
||||
|
||||
// Send email
|
||||
if ($this->mailable && $this->channel !== Channel::InApp) {
|
||||
// Send email (respects user preferences)
|
||||
if ($this->mailable && $this->channel !== Channel::InApp && $this->user->wantsEmailFor($this->type->value)) {
|
||||
Mail::to($this->user)->send($this->mailable);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
app/Models/NotificationPreference.php
Normal file
35
app/Models/NotificationPreference.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NotificationPreference extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'post_published',
|
||||
'post_failed',
|
||||
'account_disconnected',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'post_published' => 'boolean',
|
||||
'post_failed' => 'boolean',
|
||||
'account_disconnected' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Cashier\Billable;
|
||||
|
|
@ -88,6 +89,27 @@ public function notifications(): HasMany
|
|||
return $this->hasMany(Notification::class);
|
||||
}
|
||||
|
||||
public function notificationPreference(): HasOne
|
||||
{
|
||||
return $this->hasOne(NotificationPreference::class);
|
||||
}
|
||||
|
||||
public function wantsEmailFor(string $type): bool
|
||||
{
|
||||
$preference = $this->notificationPreference;
|
||||
|
||||
if (! $preference) {
|
||||
return true; // Default: all enabled
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
'post_published' => $preference->post_published,
|
||||
'post_failed', 'post_partially_published' => $preference->post_failed,
|
||||
'account_disconnected' => $preference->account_disconnected,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has an active subscription.
|
||||
* In self-hosted mode, always returns true (no subscription required).
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Listeners\StripeEventListener;
|
||||
use App\Models\Media;
|
||||
use App\Models\Notification;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
|
|
@ -78,6 +79,7 @@ protected function configureMorphMap(): void
|
|||
Relation::enforceMorphMap([
|
||||
'media' => Media::class,
|
||||
'notification' => Notification::class,
|
||||
'notificationPreference' => NotificationPreference::class,
|
||||
'post' => Post::class,
|
||||
'postPlatform' => PostPlatform::class,
|
||||
'socialAccount' => SocialAccount::class,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notification_preferences', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('user_id');
|
||||
$table->boolean('post_published')->default(true);
|
||||
$table->boolean('post_failed')->default(true);
|
||||
$table->boolean('account_disconnected')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
$table->unique('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notification_preferences');
|
||||
}
|
||||
};
|
||||
|
|
@ -26,4 +26,6 @@
|
|||
'date_picker' => [
|
||||
'select' => 'Select date',
|
||||
],
|
||||
|
||||
'cancel' => 'Cancel',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,9 +9,23 @@
|
|||
'password' => 'Password',
|
||||
'workspace' => 'Workspace',
|
||||
'members' => 'Members',
|
||||
'notifications' => 'Notifications',
|
||||
'billing' => 'Billing',
|
||||
],
|
||||
|
||||
'notifications' => [
|
||||
'title' => 'Notification preferences',
|
||||
'heading' => 'Email notifications',
|
||||
'description' => 'Choose which email notifications you want to receive',
|
||||
'post_published' => 'Post published',
|
||||
'post_published_description' => 'Receive an email when your post is published successfully',
|
||||
'post_failed' => 'Post failed',
|
||||
'post_failed_description' => 'Receive an email when your post fails to publish',
|
||||
'account_disconnected' => 'Account disconnected',
|
||||
'account_disconnected_description' => 'Receive an email when a social account is disconnected',
|
||||
'save' => 'Save preferences',
|
||||
],
|
||||
|
||||
'profile' => [
|
||||
'title' => 'Profile settings',
|
||||
'photo_heading' => 'Profile photo',
|
||||
|
|
@ -141,6 +155,7 @@
|
|||
'photo_deleted' => 'Photo removed successfully!',
|
||||
'logo_updated' => 'Logo uploaded successfully!',
|
||||
'logo_deleted' => 'Logo removed successfully!',
|
||||
'notifications_updated' => 'Notification preferences updated!',
|
||||
],
|
||||
|
||||
'api_keys' => [
|
||||
|
|
|
|||
|
|
@ -26,4 +26,6 @@
|
|||
'date_picker' => [
|
||||
'select' => 'Seleccionar fecha',
|
||||
],
|
||||
|
||||
'cancel' => 'Cancelar',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,9 +9,23 @@
|
|||
'password' => 'Contraseña',
|
||||
'workspace' => 'Workspace',
|
||||
'members' => 'Miembros',
|
||||
'notifications' => 'Notificaciones',
|
||||
'billing' => 'Facturación',
|
||||
],
|
||||
|
||||
'notifications' => [
|
||||
'title' => 'Preferencias de notificaciones',
|
||||
'heading' => 'Notificaciones por correo',
|
||||
'description' => 'Elige qué notificaciones por correo deseas recibir',
|
||||
'post_published' => 'Post publicado',
|
||||
'post_published_description' => 'Recibir un correo cuando tu post se publique correctamente',
|
||||
'post_failed' => 'Post fallido',
|
||||
'post_failed_description' => 'Recibir un correo cuando tu post falle al publicar',
|
||||
'account_disconnected' => 'Cuenta desconectada',
|
||||
'account_disconnected_description' => 'Recibir un correo cuando una cuenta social se desconecte',
|
||||
'save' => 'Guardar preferencias',
|
||||
],
|
||||
|
||||
'profile' => [
|
||||
'title' => 'Configuración del perfil',
|
||||
'photo_heading' => 'Foto de perfil',
|
||||
|
|
@ -141,6 +155,7 @@
|
|||
'photo_deleted' => '¡Foto eliminada correctamente!',
|
||||
'logo_updated' => '¡Logo subido correctamente!',
|
||||
'logo_deleted' => '¡Logo eliminado correctamente!',
|
||||
'notifications_updated' => '¡Preferencias de notificaciones actualizadas!',
|
||||
],
|
||||
|
||||
'api_keys' => [
|
||||
|
|
|
|||
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
|
|
@ -26,4 +26,6 @@
|
|||
'date_picker' => [
|
||||
'select' => 'Selecionar data',
|
||||
],
|
||||
|
||||
'cancel' => 'Cancelar',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,9 +9,23 @@
|
|||
'password' => 'Senha',
|
||||
'workspace' => 'Workspace',
|
||||
'members' => 'Membros',
|
||||
'notifications' => 'Notificações',
|
||||
'billing' => 'Faturamento',
|
||||
],
|
||||
|
||||
'notifications' => [
|
||||
'title' => 'Preferências de notificações',
|
||||
'heading' => 'Notificações por e-mail',
|
||||
'description' => 'Escolha quais notificações por e-mail deseja receber',
|
||||
'post_published' => 'Post publicado',
|
||||
'post_published_description' => 'Receber um e-mail quando seu post for publicado com sucesso',
|
||||
'post_failed' => 'Post falhou',
|
||||
'post_failed_description' => 'Receber um e-mail quando seu post falhar ao publicar',
|
||||
'account_disconnected' => 'Conta desconectada',
|
||||
'account_disconnected_description' => 'Receber um e-mail quando uma conta social for desconectada',
|
||||
'save' => 'Salvar preferências',
|
||||
],
|
||||
|
||||
'profile' => [
|
||||
'title' => 'Configurações do perfil',
|
||||
'photo_heading' => 'Foto do perfil',
|
||||
|
|
@ -141,6 +155,7 @@
|
|||
'photo_deleted' => 'Foto removida com sucesso!',
|
||||
'logo_updated' => 'Logo enviado com sucesso!',
|
||||
'logo_deleted' => 'Logo removido com sucesso!',
|
||||
'notifications_updated' => 'Preferências de notificações atualizadas!',
|
||||
],
|
||||
|
||||
'api_keys' => [
|
||||
|
|
|
|||
|
|
@ -6,24 +6,31 @@ import type { BreadcrumbItem } from '@/types';
|
|||
withDefaults(
|
||||
defineProps<{
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
showSidebarTrigger?: boolean;
|
||||
}>(),
|
||||
{
|
||||
breadcrumbs: () => [],
|
||||
showSidebarTrigger: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
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">
|
||||
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">
|
||||
<div class="flex items-center gap-2">
|
||||
<SidebarTrigger class="-ml-1" />
|
||||
<SidebarTrigger v-if="showSidebarTrigger" class="-ml-1" />
|
||||
<slot name="left">
|
||||
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="right" />
|
||||
<div v-if="$slots.center" class="flex items-center">
|
||||
<slot name="center" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ const handleOpenChange = (value: boolean) => {
|
|||
<Button type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? $t('hashtags.create.submitting') : $t('hashtags.create.submit') }}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" @click="open = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -96,6 +96,9 @@ const submit = () => {
|
|||
<Button type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? $t('hashtags.edit.submitting') : $t('hashtags.edit.submit') }}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" @click="open = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ const handleOpenChange = (value: boolean) => {
|
|||
<Button type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? $t('labels.create.submitting') : $t('labels.create.submit') }}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" @click="open = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -112,6 +112,9 @@ const submit = () => {
|
|||
<Button type="submit" :disabled="form.processing">
|
||||
{{ form.processing ? $t('labels.edit.submitting') : $t('labels.edit.submit') }}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" @click="open = false">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ withDefaults(defineProps<Props>(), {
|
|||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs" :full-width="fullWidth">
|
||||
<template v-if="$slots['header-left']" #header-left>
|
||||
<slot name="header-left" />
|
||||
</template>
|
||||
<template v-if="$slots['header-center']" #header-center>
|
||||
<slot name="header-center" />
|
||||
</template>
|
||||
<template v-if="$slots['header-right']" #header-right>
|
||||
<slot name="header-right" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,13 @@ withDefaults(defineProps<Props>(), {
|
|||
<SidebarProvider :default-open="isOpen">
|
||||
<AppSidebar />
|
||||
<SidebarInset class="flex h-screen flex-col overflow-hidden">
|
||||
<AppHeader :breadcrumbs="breadcrumbs">
|
||||
<AppHeader :breadcrumbs="$slots['header-left'] ? [] : breadcrumbs" :show-sidebar-trigger="!$slots['header-left']">
|
||||
<template v-if="$slots['header-left']" #left>
|
||||
<slot name="header-left" />
|
||||
</template>
|
||||
<template v-if="$slots['header-center']" #center>
|
||||
<slot name="header-center" />
|
||||
</template>
|
||||
<template v-if="$slots['header-right']" #right>
|
||||
<slot name="header-right" />
|
||||
</template>
|
||||
|
|
@ -33,7 +39,7 @@ withDefaults(defineProps<Props>(), {
|
|||
<div
|
||||
:class="
|
||||
fullWidth
|
||||
? 'flex min-h-0 flex-1 flex-col overflow-hidden'
|
||||
? 'flex min-h-0 flex-1 flex-col overflow-y-auto'
|
||||
: 'flex-1 overflow-y-auto'
|
||||
"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { toUrl } from '@/lib/utils';
|
|||
import { index as apiKeys } from '@/routes/app/api-keys';
|
||||
import { index as billing } from '@/routes/app/billing';
|
||||
import { edit as editProfile } from '@/routes/app/profile';
|
||||
import { preferences as notificationPreferences } from '@/routes/app/notifications';
|
||||
import { edit as editPassword } from '@/routes/app/user-password';
|
||||
import { settings as workspaceSettings } from '@/routes/app/workspace';
|
||||
import { type NavItem, type SharedData } from '@/types';
|
||||
|
|
@ -26,6 +27,10 @@ const navItems = computed<NavItem[]>(() => {
|
|||
title: trans('settings.nav.password'),
|
||||
href: editPassword(),
|
||||
},
|
||||
{
|
||||
title: trans('settings.nav.notifications'),
|
||||
href: notificationPreferences(),
|
||||
},
|
||||
];
|
||||
|
||||
if (canManageWorkspace.value) {
|
||||
|
|
|
|||
|
|
@ -62,18 +62,13 @@ const getHashtagCount = (hashtags: string): number => {
|
|||
<Head :title="$t('hashtags.title')" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<template #header-right>
|
||||
<Button @click="isCreateDialogOpen = true">
|
||||
{{ $t('hashtags.new_group') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div v-if="hashtags.length > 0" class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{{ $t('hashtags.title') }}</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{{ $t('hashtags.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="isCreateDialogOpen = true">
|
||||
{{ $t('hashtags.new_group') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="hashtags.length === 0" class="flex flex-col items-center justify-center py-16">
|
||||
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
|
|
|
|||
|
|
@ -58,18 +58,13 @@ const handleDelete = (labelId: string) => {
|
|||
<Head :title="$t('labels.title')" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<template #header-right>
|
||||
<Button @click="isCreateDialogOpen = true">
|
||||
{{ $t('labels.new_label') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div v-if="labels.length > 0" class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{{ $t('labels.title') }}</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{{ $t('labels.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="isCreateDialogOpen = true">
|
||||
{{ $t('labels.new_label') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="labels.length === 0" class="flex flex-col items-center justify-center py-16">
|
||||
<div class="h-16 w-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
|
|
|
|||
|
|
@ -71,10 +71,6 @@ const effectiveView = computed(() => {
|
|||
return isMobile.value ? 'day' : props.view;
|
||||
});
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
|
||||
{ title: trans('posts.title'), href: postsIndex.url() },
|
||||
{ title: trans('calendar.title'), href: calendar.url() },
|
||||
]);
|
||||
|
||||
// Generate weekday names based on dayjs locale (respects weekStart config)
|
||||
const weekdayNames = computed(() => {
|
||||
|
|
@ -263,46 +259,45 @@ const formatTime = (scheduledAt: string): string => {
|
|||
|
||||
<Head :title="$t('calendar.title')" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs" :fullWidth="true">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b gap-2">
|
||||
<div class="flex items-center gap-2 lg:gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" @click="navigate(-1)">
|
||||
<IconChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" @click="navigate(1)">
|
||||
<IconChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="goToToday">
|
||||
{{ $t('calendar.today') }}
|
||||
<AppLayout :fullWidth="true">
|
||||
<template #header-left>
|
||||
<Button variant="outline" size="icon" @click="navigate(-1)">
|
||||
<IconChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" @click="goToToday">
|
||||
{{ $t('calendar.today') }}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" @click="navigate(1)">
|
||||
<IconChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
<DatePicker v-if="isMobile" v-model="selectedDate" @update:model-value="goToDate" />
|
||||
</template>
|
||||
|
||||
<template #header-center>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #header-right>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tabs v-if="!isMobile" :default-value="view" @update:model-value="switchView">
|
||||
<TabsList class="h-10">
|
||||
<TabsTrigger value="day">{{ $t('calendar.day') }}</TabsTrigger>
|
||||
<TabsTrigger value="week">{{ $t('calendar.week') }}</TabsTrigger>
|
||||
<TabsTrigger value="month">{{ $t('calendar.month') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Link :href="storePost.url()" method="post">
|
||||
<Button>
|
||||
{{ $t('calendar.new_post') }}
|
||||
</Button>
|
||||
<!-- Date Picker for mobile day view -->
|
||||
<DatePicker v-if="isMobile" v-model="selectedDate" @update:model-value="goToDate" />
|
||||
<!-- Title for desktop -->
|
||||
<h1 class="hidden lg:block text-lg font-semibold">
|
||||
{{ headerTitle }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 lg:gap-4">
|
||||
<!-- View Tabs (hidden on mobile) -->
|
||||
<Tabs v-if="!isMobile" :default-value="view" @update:model-value="switchView">
|
||||
<TabsList>
|
||||
<TabsTrigger value="day">{{ $t('calendar.day') }}</TabsTrigger>
|
||||
<TabsTrigger value="week">{{ $t('calendar.week') }}</TabsTrigger>
|
||||
<TabsTrigger value="month">{{ $t('calendar.month') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Link :href="storePost.url()" method="post">
|
||||
<Button size="sm" class="lg:size-default">
|
||||
<IconPlus class="h-4 w-4 lg:hidden" />
|
||||
<span class="hidden lg:inline">{{ $t('calendar.new_post') }}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<!-- Day View (mobile or when view=day) -->
|
||||
<div v-if="effectiveView === 'day'" class="flex-1 overflow-y-auto">
|
||||
|
|
@ -314,13 +309,6 @@ const formatTime = (scheduledAt: string): string => {
|
|||
</div>
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
<!-- Add Post Button -->
|
||||
<Link :href="storePost.url({ query: { date: currentDay.format('YYYY-MM-DD') } })" method="post"
|
||||
class="flex items-center justify-center gap-2 p-4 rounded-lg border border-dashed border-muted-foreground/30 text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 transition-colors">
|
||||
<IconPlus class="h-5 w-5" />
|
||||
<span>{{ $t('calendar.new_post') }}</span>
|
||||
</Link>
|
||||
|
||||
<!-- Posts List -->
|
||||
<div v-if="dayPosts.length > 0" class="space-y-3">
|
||||
<Link v-for="post in dayPosts" :key="post.id" :href="getPostUrl(post)" class="block">
|
||||
|
|
@ -417,7 +405,7 @@ const formatTime = (scheduledAt: string): string => {
|
|||
</div>
|
||||
|
||||
<!-- Month View -->
|
||||
<div v-else class="flex-1 flex flex-col overflow-hidden">
|
||||
<div v-else class="flex-1 flex flex-col">
|
||||
<!-- Weekday Headers -->
|
||||
<div class="grid grid-cols-7 divide-x border-b bg-muted/30">
|
||||
<div v-for="day in weekdayNames" :key="day"
|
||||
|
|
|
|||
|
|
@ -696,12 +696,12 @@ const appendHashtags = (hashtag: WorkspaceHashtag) => {
|
|||
|
||||
<span class="h-4 w-px bg-border" />
|
||||
|
||||
<Button type="button" variant="secondary" size="sm" class="shrink-0"
|
||||
<Button type="button" variant="secondary" class="shrink-0"
|
||||
:disabled="!canSubmit || isSubmitting || isSaving" @click="submit('scheduled')">
|
||||
{{ $t('posts.edit.schedule') }}
|
||||
</Button>
|
||||
|
||||
<Button type="button" size="sm" class="shrink-0" :disabled="!canSubmit || isSubmitting || isSaving"
|
||||
<Button type="button" class="shrink-0" :disabled="!canSubmit || isSubmitting || isSaving"
|
||||
@click="submit('publishing')">
|
||||
{{ $t('posts.edit.publish') }}
|
||||
</Button>
|
||||
|
|
@ -783,7 +783,7 @@ const appendHashtags = (hashtag: WorkspaceHashtag) => {
|
|||
|
||||
<!-- Mobile Actions Button (edit mode only) -->
|
||||
<div v-if="!isReadOnly" class="lg:hidden flex items-center gap-2">
|
||||
<Button type="button" size="sm" :disabled="!canSubmit || isSubmitting || isSaving"
|
||||
<Button type="button" :disabled="!canSubmit || isSubmitting || isSaving"
|
||||
@click="submit('publishing')">
|
||||
{{ $t('posts.edit.publish') }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -177,22 +177,15 @@ const handleDelete = (post: Post) => {
|
|||
<Head :title="pageTitle" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<template #header-right>
|
||||
<Link :href="storePost.url()" method="post">
|
||||
<Button>
|
||||
{{ $t('posts.new_post') }}
|
||||
</Button>
|
||||
</Link>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{{ pageTitle }}</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{{ pageDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Link :href="storePost.url()" method="post">
|
||||
<Button>
|
||||
{{ $t('posts.new_post') }}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="posts.data.length === 0"
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const page = usePage();
|
|||
const newToken = computed(() => (page.props.flash as Record<string, unknown>)?.plainToken as string | undefined);
|
||||
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.title'), href: apiKeysIndex.url() },
|
||||
{ title: trans('settings.api_keys.title'), href: apiKeysIndex.url() },
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ interface Props {
|
|||
defineProps<Props>();
|
||||
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.members.title'), href: membersRoute.url() },
|
||||
{ title: trans('settings.title'), href: membersRoute.url() },
|
||||
{ title: trans('settings.nav.members'), href: membersRoute.url() },
|
||||
]);
|
||||
|
||||
const form = useForm({
|
||||
|
|
|
|||
104
resources/js/pages/settings/Notifications.vue
Normal file
104
resources/js/pages/settings/Notifications.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { preferences } from '@/routes/app/notifications';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
|
||||
interface Preferences {
|
||||
post_published: boolean;
|
||||
post_failed: boolean;
|
||||
account_disconnected: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
preferences: Preferences;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: trans('settings.title'), href: preferences().url },
|
||||
{ title: trans('settings.nav.notifications'), href: preferences().url },
|
||||
]);
|
||||
|
||||
const postPublished = ref(props.preferences.post_published);
|
||||
const postFailed = ref(props.preferences.post_failed);
|
||||
const accountDisconnected = ref(props.preferences.account_disconnected);
|
||||
const processing = ref(false);
|
||||
|
||||
const submit = () => {
|
||||
processing.value = true;
|
||||
|
||||
router.put(preferences().url, {
|
||||
post_published: postPublished.value,
|
||||
post_failed: postFailed.value,
|
||||
account_disconnected: accountDisconnected.value,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
onFinish: () => {
|
||||
processing.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||
<Head :title="$t('settings.notifications.title')" />
|
||||
|
||||
<h1 class="sr-only">{{ $t('settings.notifications.title') }}</h1>
|
||||
|
||||
<SettingsLayout>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.notifications.heading')"
|
||||
:description="$t('settings.notifications.description')"
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="post_published">{{ $t('settings.notifications.post_published') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.notifications.post_published_description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="post_published" v-model="postPublished" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="post_failed">{{ $t('settings.notifications.post_failed') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.notifications.post_failed_description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="post_failed" v-model="postFailed" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="account_disconnected">{{ $t('settings.notifications.account_disconnected') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('settings.notifications.account_disconnected_description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="account_disconnected" v-model="accountDisconnected" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button :disabled="processing" class="self-start" @click="submit">
|
||||
{{ $t('settings.notifications.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
|
@ -15,10 +15,8 @@ import { edit } from '@/routes/app/user-password';
|
|||
import { type BreadcrumbItem } from '@/types';
|
||||
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||
{
|
||||
title: trans('settings.password.title'),
|
||||
href: edit().url,
|
||||
},
|
||||
{ title: trans('settings.title'), href: edit().url },
|
||||
{ title: trans('settings.nav.password'), href: edit().url },
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,8 @@ const page = usePage();
|
|||
const user = computed(() => page.props.auth.user);
|
||||
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||
{
|
||||
title: trans('settings.profile.title'),
|
||||
href: edit().url,
|
||||
},
|
||||
{ title: trans('settings.title'), href: edit().url },
|
||||
{ title: trans('settings.nav.profile'), href: edit().url },
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -67,10 +67,8 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||
{
|
||||
title: trans('settings.workspace.title'),
|
||||
href: settings().url,
|
||||
},
|
||||
{ title: trans('settings.title'), href: settings().url },
|
||||
{ title: trans('settings.nav.workspace'), href: settings().url },
|
||||
]);
|
||||
|
||||
const timezone = ref(props.workspace.timezone);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Http\Controllers\App\NotificationController;
|
||||
use App\Http\Controllers\App\OnboardingController;
|
||||
use App\Http\Controllers\App\PostController;
|
||||
use App\Http\Controllers\App\Settings\NotificationPreferenceController;
|
||||
use App\Http\Controllers\App\Settings\PasswordController;
|
||||
use App\Http\Controllers\App\Settings\ProfileController;
|
||||
use App\Http\Controllers\App\WorkspaceController;
|
||||
|
|
@ -187,6 +188,8 @@ function () {
|
|||
Route::put('settings/password', [PasswordController::class, 'update'])
|
||||
->middleware('throttle:6,1')
|
||||
->name('app.user-password.update');
|
||||
Route::get('settings/notifications', [NotificationPreferenceController::class, 'edit'])->name('app.notifications.preferences');
|
||||
Route::put('settings/notifications', [NotificationPreferenceController::class, 'update'])->name('app.notifications.preferences.update');
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
|||
127
tests/Feature/Settings/NotificationPreferenceTest.php
Normal file
127
tests/Feature/Settings/NotificationPreferenceTest.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Enums\User\Setup;
|
||||
use App\Jobs\SendNotification;
|
||||
use App\Mail\PostPublished;
|
||||
use App\Models\Notification;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('notification preferences page requires authentication', function () {
|
||||
$response = $this->get(route('app.notifications.preferences'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('notification preferences page renders', function () {
|
||||
$response = $this->actingAs($this->user)->get(route('app.notifications.preferences'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('settings/Notifications')
|
||||
->has('preferences')
|
||||
);
|
||||
});
|
||||
|
||||
test('notification preferences are created with defaults on first visit', function () {
|
||||
expect(NotificationPreference::where('user_id', $this->user->id)->count())->toBe(0);
|
||||
|
||||
$this->actingAs($this->user)->get(route('app.notifications.preferences'));
|
||||
|
||||
$preference = NotificationPreference::where('user_id', $this->user->id)->first();
|
||||
expect($preference)->not->toBeNull();
|
||||
expect($preference->post_published)->toBeTrue();
|
||||
expect($preference->post_failed)->toBeTrue();
|
||||
expect($preference->account_disconnected)->toBeTrue();
|
||||
});
|
||||
|
||||
test('user can update notification preferences', function () {
|
||||
$response = $this->actingAs($this->user)->put(route('app.notifications.preferences.update'), [
|
||||
'post_published' => false,
|
||||
'post_failed' => true,
|
||||
'account_disconnected' => false,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$preference = NotificationPreference::where('user_id', $this->user->id)->first();
|
||||
expect($preference->post_published)->toBeFalse();
|
||||
expect($preference->post_failed)->toBeTrue();
|
||||
expect($preference->account_disconnected)->toBeFalse();
|
||||
});
|
||||
|
||||
test('update validates boolean fields', function () {
|
||||
$response = $this->actingAs($this->user)->put(route('app.notifications.preferences.update'), [
|
||||
'post_published' => 'invalid',
|
||||
'post_failed' => true,
|
||||
'account_disconnected' => true,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('post_published');
|
||||
});
|
||||
|
||||
test('wantsEmailFor respects preferences', function () {
|
||||
NotificationPreference::create([
|
||||
'user_id' => $this->user->id,
|
||||
'post_published' => false,
|
||||
'post_failed' => true,
|
||||
'account_disconnected' => false,
|
||||
]);
|
||||
|
||||
expect($this->user->wantsEmailFor('post_published'))->toBeFalse();
|
||||
expect($this->user->wantsEmailFor('post_failed'))->toBeTrue();
|
||||
expect($this->user->wantsEmailFor('post_partially_published'))->toBeTrue(); // maps to post_failed
|
||||
expect($this->user->wantsEmailFor('account_disconnected'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('wantsEmailFor defaults to true when no preferences exist', function () {
|
||||
expect($this->user->wantsEmailFor('post_published'))->toBeTrue();
|
||||
expect($this->user->wantsEmailFor('post_failed'))->toBeTrue();
|
||||
expect($this->user->wantsEmailFor('account_disconnected'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('send notification respects email preferences', function () {
|
||||
Mail::fake();
|
||||
|
||||
NotificationPreference::create([
|
||||
'user_id' => $this->user->id,
|
||||
'post_published' => false,
|
||||
'post_failed' => true,
|
||||
'account_disconnected' => true,
|
||||
]);
|
||||
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
// Should NOT send email (post_published disabled)
|
||||
(new SendNotification(
|
||||
user: $this->user,
|
||||
workspaceId: $this->workspace->id,
|
||||
type: Type::PostPublished,
|
||||
channel: Channel::Both,
|
||||
title: 'Test',
|
||||
body: 'Test',
|
||||
mailable: new PostPublished($post),
|
||||
))->handle();
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
|
||||
// In-app notification should still be created
|
||||
expect(Notification::count())->toBe(1);
|
||||
});
|
||||
Loading…
Reference in a new issue