new front end

This commit is contained in:
Mark Gregory
2026-09-09 16:56:31 +01:00
parent f61e14e655
commit 2078dbf637
18 changed files with 632 additions and 14 deletions
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class SupportTicketController extends Controller
{
public function index(Request $request)
{
$tickets = SupportTicket::query()
->with('assignee')
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->latest()
->paginate(50)
->withQueryString();
return view('admin.support.index', compact('tickets'));
}
public function show(SupportTicket $ticket)
{
$ticket->load(['user', 'assignee']);
if (auth()->user()) {
auth()->user()->unreadNotifications()->get()
->filter(fn ($notification) => (int) ($notification->data['support_ticket_id'] ?? 0) === $ticket->id)
->each->markAsRead();
}
return view('admin.support.show', [
'ticket' => $ticket,
'admins' => User::query()->where('is_admin', true)->orderBy('name')->get(['id', 'name', 'email']),
]);
}
public function update(Request $request, SupportTicket $ticket): RedirectResponse
{
$data = $request->validate([
'status' => ['required', 'in:new,open,waiting,resolved,closed'],
'priority' => ['required', 'in:low,normal,high,urgent'],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
]);
if ($data['assigned_to'] ?? null) {
abort_unless(User::whereKey($data['assigned_to'])->where('is_admin', true)->exists(), 422);
}
$data['resolved_at'] = in_array($data['status'], ['resolved', 'closed'], true)
? ($ticket->resolved_at ?? now())
: null;
$ticket->update($data);
return back()->with('status', 'Support ticket updated.');
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Support;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\User;
use App\Notifications\NewSupportTicket;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
final class SupportController extends Controller
{
public function create(Request $request)
{
return view('support', [
'categories' => [
'technical' => 'Technical support',
'licensing' => 'Licence & activation',
'billing' => 'Billing & subscription',
'feature' => 'Feature request',
'privacy' => 'Privacy / data request',
'general' => 'General enquiry',
],
]);
}
public function store(Request $request): RedirectResponse
{
// Honeypot: bots tend to fill hidden fields. Return success without retaining spam.
if (filled($request->input('company_website'))) {
return back()->with('status', 'Your support request has been received.');
}
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email:rfc', 'max:254'],
'category' => ['required', 'in:technical,licensing,billing,feature,privacy,general'],
'subject' => ['required', 'string', 'max:180'],
'message' => ['required', 'string', 'min:10', 'max:10000'],
'privacy_notice' => ['accepted'],
]);
$ticket = SupportTicket::create([
'user_id' => $request->user()?->id,
'reference' => $this->reference(),
'name' => $data['name'],
'email' => strtolower($data['email']),
'category' => $data['category'],
'subject' => $data['subject'],
'message' => $data['message'],
'privacy_notice_accepted_at' => now(),
]);
$this->notifyTeam($ticket);
Mail::raw(
"We received your Rsync Studio support request.\n\nReference: {$ticket->reference}\nSubject: {$ticket->subject}\n\nWe will reply to this email address. Please keep the reference in any follow-up.",
fn ($mail) => $mail->to($ticket->email)->subject("Rsync Studio support request {$ticket->reference}")
);
return redirect()->route('support')->with('status', "Support request {$ticket->reference} received. A confirmation has been emailed to you.");
}
private function notifyTeam(SupportTicket $ticket): void
{
if (config('rsync-studio.support.notify_admin_users', true)) {
User::query()->where('is_admin', true)->get()->each->notify(new NewSupportTicket($ticket));
}
$emails = collect(config('rsync-studio.support.alert_emails', []))
->filter(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL))
->unique()
->values();
if ($emails->isNotEmpty()) {
Notification::route('mail', $emails->all())->notify(new NewSupportTicket($ticket));
}
}
private function reference(): string
{
do {
$reference = 'RS-'.now()->format('ymd').'-'.Str::upper(Str::random(6));
} while (SupportTicket::where('reference', $reference)->exists());
return $reference;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SupportTicket extends Model
{
use Prunable;
protected $fillable = [
'user_id', 'assigned_to', 'reference', 'name', 'email', 'category',
'subject', 'message', 'status', 'priority', 'privacy_notice_accepted_at',
'resolved_at',
];
protected function casts(): array
{
return [
'privacy_notice_accepted_at' => 'datetime',
'resolved_at' => 'datetime',
];
}
public function user(): BelongsTo { return $this->belongsTo(User::class); }
public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assigned_to'); }
public function prunable(): Builder
{
$days = max(30, (int) config('rsync-studio.privacy.support_retention_days', 730));
return static::query()
->whereNotNull('resolved_at')
->where('resolved_at', '<=', now()->subDays($days));
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Notifications;
use App\Models\SupportTicket;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\AnonymousNotifiable;
class NewSupportTicket extends Notification
{
use Queueable;
public function __construct(public readonly SupportTicket $ticket) {}
public function via(object $notifiable): array
{
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : ['database', 'mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("New Rsync Studio support request {$this->ticket->reference}")
->greeting('New support request')
->line("{$this->ticket->name} <{$this->ticket->email}>")
->line("Category: {$this->ticket->category}")
->line("Subject: {$this->ticket->subject}")
->line($this->ticket->message)
->action('Open support ticket', route('admin.support.show', $this->ticket));
}
public function toArray(object $notifiable): array
{
return [
'support_ticket_id' => $this->ticket->id,
'reference' => $this->ticket->reference,
'subject' => $this->ticket->subject,
'email' => $this->ticket->email,
];
}
}
+16
View File
@@ -10,6 +10,22 @@ return [
'purchase_url' => env('RSYNC_STUDIO_PURCHASE_URL'),
'portal_url' => env('RSYNC_STUDIO_PORTAL_URL'),
'support_url' => env('RSYNC_STUDIO_SUPPORT_URL'),
'legal' => [
'controller_name' => env('RSYNC_STUDIO_LEGAL_NAME', 'Rsync Studio'),
'privacy_email' => env('RSYNC_STUDIO_PRIVACY_EMAIL', env('MAIL_FROM_ADDRESS')),
'support_email' => env('RSYNC_STUDIO_SUPPORT_EMAIL', env('MAIL_FROM_ADDRESS')),
'country' => env('RSYNC_STUDIO_LEGAL_COUNTRY', 'United Kingdom'),
],
'privacy' => [
'policy_version' => env('RSYNC_STUDIO_PRIVACY_VERSION', '2026-09-09'),
'cookie_version' => env('RSYNC_STUDIO_COOKIE_VERSION', '1'),
'support_retention_days' => (int) env('RSYNC_STUDIO_SUPPORT_RETENTION_DAYS', 730),
],
'support' => [
'alert_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('RSYNC_STUDIO_SUPPORT_ALERT_EMAILS', ''))))),
'notify_admin_users' => filter_var(env('RSYNC_STUDIO_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL),
],
'paddle' => [
'api_key' => env('PADDLE_API_KEY'),
'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'),
Binary file not shown.
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('support_tickets')) {
Schema::create('support_tickets', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
$table->string('reference', 24)->unique();
$table->string('name', 120);
$table->string('email', 254)->index();
$table->string('category', 40)->default('general');
$table->string('subject', 180);
$table->text('message');
$table->string('status', 20)->default('new')->index();
$table->string('priority', 20)->default('normal');
$table->timestamp('privacy_notice_accepted_at');
$table->timestamp('resolved_at')->nullable()->index();
$table->timestamps();
});
}
if (! Schema::hasTable('notifications')) {
Schema::create('notifications', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
}
public function down(): void
{
Schema::dropIfExists('support_tickets');
Schema::dropIfExists('notifications');
}
};
+6 -1
View File
@@ -1 +1,6 @@
@extends('layouts.app') @section('content')<h1>Licensing administration</h1><div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a>@endsection
@extends('layouts.app')
@section('content')
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section>
<div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
@endsection
@@ -0,0 +1,24 @@
@extends('layouts.app', ['title' => 'Support Inbox — Rsync Studio'])
@section('content')
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Support inbox</h1><p class="lead compact">Customer, licence, billing and privacy requests.</p></div></section>
<form method="get" class="filter-bar">
<label for="status">Status</label>
<select id="status" name="status" onchange="this.form.submit()">
<option value="">All</option>
@foreach(['new','open','waiting','resolved','closed'] as $status)<option value="{{ $status }}" @selected(request('status') === $status)>{{ ucfirst($status) }}</option>@endforeach
</select>
</form>
<div class="admin-ticket-list">
@forelse($tickets as $ticket)
<a class="admin-ticket-row" href="{{ route('admin.support.show', $ticket) }}">
<div><span class="ticket-ref">{{ $ticket->reference }}</span><strong>{{ $ticket->subject }}</strong><small>{{ $ticket->name }} · {{ $ticket->email }}</small></div>
<span class="ticket-category">{{ ucfirst($ticket->category) }}</span>
<span class="ticket-status status-{{ $ticket->status }}">{{ ucfirst($ticket->status) }}</span>
<time>{{ $ticket->created_at->diffForHumans() }}</time>
</a>
@empty
<div class="empty-state"><h2>No support requests</h2><p>The support inbox is empty.</p></div>
@endforelse
</div>
{{ $tickets->links() }}
@endsection
@@ -0,0 +1,14 @@
@extends('layouts.app', ['title' => $ticket->reference.' — Support'])
@section('content')
<a class="back-link" href="{{ route('admin.support.index') }}">← Support inbox</a>
<section class="ticket-detail-head"><div><span class="ticket-ref">{{ $ticket->reference }}</span><h1>{{ $ticket->subject }}</h1><p>{{ $ticket->name }} · <a href="mailto:{{ $ticket->email }}?subject=Re:%20{{ rawurlencode($ticket->reference.' '.$ticket->subject) }}">{{ $ticket->email }}</a></p></div><span class="ticket-status status-{{ $ticket->status }}">{{ ucfirst($ticket->status) }}</span></section>
<div class="detail-grid">
<article class="panel ticket-message"><h2>Request</h2><div class="ticket-meta"><span>{{ ucfirst($ticket->category) }}</span><span>{{ $ticket->created_at->format('j M Y H:i') }}</span></div><div class="message-body">{!! nl2br(e($ticket->message)) !!}</div><a class="btn btn-primary" href="mailto:{{ $ticket->email }}?subject=Re:%20{{ rawurlencode($ticket->reference.' '.$ticket->subject) }}">Reply by email</a></article>
<aside class="panel"><h2>Manage</h2><form method="post" action="{{ route('admin.support.update', $ticket) }}">@csrf @method('PATCH')
<label>Status<select name="status">@foreach(['new','open','waiting','resolved','closed'] as $status)<option value="{{ $status }}" @selected($ticket->status === $status)>{{ ucfirst($status) }}</option>@endforeach</select></label>
<label>Priority<select name="priority">@foreach(['low','normal','high','urgent'] as $priority)<option value="{{ $priority }}" @selected($ticket->priority === $priority)>{{ ucfirst($priority) }}</option>@endforeach</select></label>
<label>Assigned to<select name="assigned_to"><option value="">Unassigned</option>@foreach($admins as $admin)<option value="{{ $admin->id }}" @selected($ticket->assigned_to === $admin->id)>{{ $admin->name }} — {{ $admin->email }}</option>@endforeach</select></label>
<button class="full-width">Save changes</button>
</form></aside>
</div>
@endsection
+99 -3
View File
@@ -70,7 +70,7 @@
.brand img{width:34px;height:34px;border-radius:9px;box-shadow:0 5px 14px rgba(37,99,235,.25)}
.nav-links{display:flex;align-items:center;gap:22px}
.nav-links a{color:#cbd5e1;text-decoration:none;font-weight:650;font-size:14px;transition:color .16s ease}
.nav-links a:hover{color:#fff}
.nav-links a:hover{color:#fff}.nav-alert-count{display:inline-flex;min-width:18px;height:18px;align-items:center;justify-content:center;margin-left:5px;padding:0 5px;border-radius:99px;background:#ef4444;color:#fff;font-size:10px;font-weight:900;vertical-align:1px}
.nav-account{margin-left:auto;display:flex;align-items:center;gap:12px;min-width:0}
.nav-email{color:#cbd5e1;font-size:13px;max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.nav-auth-link{color:#e2e8f0;text-decoration:none;font-weight:650;font-size:14px}
@@ -135,7 +135,7 @@
.portal-header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:26px}.licence-list{display:grid;gap:16px}.licence-card{background:#fff;border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow-sm);overflow:hidden}.licence-card-featured{border-color:#c6d7fb}.licence-main{padding:24px 26px}.licence-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:20px}.licence-title-row h2{font-size:22px;margin:10px 0 2px;letter-spacing:-.015em}.licence-key{margin:0}.badge-row{display:flex;gap:7px;flex-wrap:wrap}.status-badge,.plan-badge,.billing-badge{display:inline-flex;align-items:center;border-radius:999px;padding:4px 9px;font-size:12px;font-weight:800}.status-active{background:var(--green-soft);color:var(--green)}.status-inactive{background:var(--red-soft);color:var(--red)}.plan-badge{background:var(--blue-soft);color:var(--blue-dark)}.billing-badge{background:#f2f4f7;color:#475467}.licence-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:24px 0 12px;padding-top:20px;border-top:1px solid var(--line)}.licence-stats>div{display:flex;flex-direction:column;gap:3px}.stat-label{font-size:12px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.04em}.licence-stats strong{font-size:15px}.activation-meter{height:7px;background:#eef2f6;border-radius:99px;overflow:hidden}.activation-meter span{display:block;height:100%;background:var(--blue);border-radius:99px}.upgrade-strip,.subscription-warning{margin-top:20px;border-radius:12px;padding:15px 16px;display:flex;align-items:center;justify-content:space-between;gap:16px}.upgrade-strip{background:#f5f8ff;border:1px solid #dbe7ff}.upgrade-strip div{display:flex;flex-direction:column;gap:2px}.upgrade-strip span{color:#475467;font-size:14px}.subscription-warning{background:#fff7ed;border:1px solid #fed7aa}.subscription-warning p{margin:3px 0 0;color:#9a3412;font-size:13px;max-width:720px}.empty-state{background:#fff;border:1px dashed #cbd5e1;border-radius:16px;padding:44px;text-align:center}.empty-state h2{margin-top:0}.history-panel{margin-top:26px;background:#fff;border:1px solid var(--line);border-radius:14px;overflow:hidden}.history-panel summary{cursor:pointer;padding:16px 20px;font-weight:800}.history-panel summary span{display:inline-flex;margin-left:6px;min-width:22px;height:22px;align-items:center;justify-content:center;border-radius:99px;background:#f2f4f7;color:#475467;font-size:12px}.history-list{border-top:1px solid var(--line)}.history-row{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:18px;padding:14px 20px;border-bottom:1px solid var(--line)}.history-row:last-child{border-bottom:0}.history-row>div:first-child{display:flex;flex-direction:column}.history-row span{font-size:13px;color:var(--muted)}.history-status{font-size:13px;color:var(--muted)}.history-row a{font-weight:700;color:var(--blue);text-decoration:none}.back-link{display:inline-block;margin-bottom:20px;color:#475467;text-decoration:none;font-weight:700}.licence-detail-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:24px}.licence-detail-head h1{font-size:32px}.detail-grid{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(280px,.7fr);gap:18px}.panel{margin:0}.panel h2{margin:0;font-size:20px}.panel-heading{display:flex;justify-content:space-between;gap:18px;align-items:flex-start;padding-bottom:18px;border-bottom:1px solid var(--line)}.panel-heading p{margin:4px 0 0;color:var(--muted)}.activation-count{font-weight:800;background:var(--blue-soft);color:var(--blue-dark);padding:6px 10px;border-radius:999px}.device-row{display:grid;grid-template-columns:42px 1fr auto;gap:14px;align-items:center;padding:17px 0;border-bottom:1px solid var(--line)}.device-row:last-child{border-bottom:0}.device-icon{width:42px;height:42px;display:flex;align-items:center;justify-content:center;background:#f2f4f7;border-radius:10px;font-size:20px}.device-copy{display:flex;flex-direction:column;min-width:0}.device-copy span,.device-copy small{color:var(--muted)}.device-copy small{margin-top:2px}.empty-inline{padding:26px 0 4px;color:var(--muted)}.subscription-panel form+form{margin-top:10px}.subscription-facts{margin:18px 0 22px}.subscription-facts>div{display:flex;justify-content:space-between;gap:16px;padding:11px 0;border-bottom:1px solid var(--line)}.subscription-facts dt{color:var(--muted)}.subscription-facts dd{margin:0;font-weight:700;text-align:right}
.site-footer{background:#0b1220;color:#94a3b8;border-top:1px solid rgba(255,255,255,.06)}.footer-shell{max-width:1220px;margin:0 auto;padding:34px 24px;display:flex;align-items:center;justify-content:space-between;gap:28px}.footer-brand{display:flex;align-items:center;gap:10px;color:#e2e8f0;font-weight:800}.footer-brand img{width:28px;height:28px;border-radius:7px}.footer-links{display:flex;gap:20px;flex-wrap:wrap}.footer-links a{color:#cbd5e1;text-decoration:none;font-size:13px;font-weight:650}.footer-links a:hover{color:#fff}.footer-copy{font-size:12px;margin-top:5px}
.site-footer{background:#0b1220;color:#94a3b8;border-top:1px solid rgba(255,255,255,.06)}.footer-shell{max-width:1220px;margin:0 auto;padding:34px 24px;display:flex;align-items:center;justify-content:space-between;gap:28px}.footer-brand{display:flex;align-items:center;gap:10px;color:#e2e8f0;font-weight:800}.footer-brand img{width:28px;height:28px;border-radius:7px}.footer-links{display:flex;gap:20px;flex-wrap:wrap}.footer-links a{color:#cbd5e1;text-decoration:none;font-size:13px;font-weight:650}.footer-links a:hover{color:#fff}.footer-cookie-link{background:none!important;border:0!important;padding:0!important;color:#cbd5e1!important;font-size:13px!important;font-weight:650!important;box-shadow:none!important}.footer-cookie-link:hover{color:#fff!important}.footer-copy{font-size:12px;margin-top:5px}
@media(max-width:980px){
.hero-grid{grid-template-columns:1fr;gap:48px}.app-window{max-width:720px;transform:none}.feature-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.workflow-grid{grid-template-columns:repeat(2,1fr)}.security-panel{grid-template-columns:1fr;gap:28px}.nav-email{display:none}
@@ -146,6 +146,24 @@
}
@media(max-width:480px){.launch-hero h1{font-size:42px}.hero-actions{flex-direction:column}.hero-actions .btn{width:100%}.app-body{grid-template-columns:1fr}.app-sidebar{display:none}.window-title{display:none}.launch-kicker{font-size:11px}.feature-card{padding:22px}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}}
/* Launch polish + GDPR/support UI */
.launch-hero{background:linear-gradient(135deg,#07111f 0%,#0b1d36 54%,#0c2b55 100%);padding:78px 24px 82px}
.launch-hero:after{content:"";position:absolute;right:-10%;bottom:-36%;width:58%;height:80%;background:radial-gradient(ellipse at center,rgba(37,99,235,.45),rgba(14,165,233,.14) 42%,transparent 68%);filter:blur(18px);pointer-events:none}
.app-window{background:#1c1c1e;border-color:rgba(255,255,255,.14);box-shadow:0 34px 70px rgba(0,0,0,.42)}
.window-bar{background:#252527;border-bottom-color:#353539;color:#d4d4d8}.app-body{background:#1c1c1e}.app-sidebar{background:#252526;border-right:1px solid #39393d}.mini-brand{color:#f4f4f5}.side-item{color:#b7b7bd}.side-item.active{background:#1677ea;color:white}.app-content{background:#1c1c1e;color:#f4f4f5}.mock-job{background:#242426;border-color:#37373b;box-shadow:none}.mock-job p,.app-statusbar{color:#9f9fa6}.app-toolbar{color:#f4f4f5}.mock-progress{background:#34343a}
.launch-section{position:relative}.feature-card{border:1px solid rgba(148,163,184,.18);box-shadow:0 14px 34px rgba(15,23,42,.06);transition:transform .18s ease,box-shadow .18s ease}.feature-card:hover{transform:translateY(-3px);box-shadow:0 20px 42px rgba(15,23,42,.10)}
.feature-card:nth-child(1){background:linear-gradient(135deg,#eef7ff,#dceeff)}.feature-card:nth-child(2){background:linear-gradient(135deg,#effdf5,#d9f8e7)}.feature-card:nth-child(3){background:linear-gradient(135deg,#fff9ed,#ffedc9)}.feature-card:nth-child(4){background:linear-gradient(135deg,#f8f1ff,#eadcff)}.feature-card:nth-child(5){background:linear-gradient(135deg,#fff1f4,#ffdce4)}.feature-card:nth-child(6){background:linear-gradient(135deg,#eafffb,#ccf7ef)}
.feature-card:nth-child(1) .feature-icon{background:#d7ebff;color:#1474e5}.feature-card:nth-child(2) .feature-icon{background:#c9f3dc;color:#079455}.feature-card:nth-child(3) .feature-icon{background:#ffe2aa;color:#e87500}.feature-card:nth-child(4) .feature-icon{background:#dfc9ff;color:#7c3aed}.feature-card:nth-child(5) .feature-icon{background:#ffc8d4;color:#e11d48}.feature-card:nth-child(6) .feature-icon{background:#bff2e9;color:#0891b2}
input,select,textarea{width:100%;padding:11px 12px;border:1px solid #cbd5e1;border-radius:9px;margin:6px 0 14px;background:#fff;color:var(--text);font:inherit}textarea{resize:vertical;min-height:150px}select{appearance:auto}.field-error{color:var(--red);font-size:13px;margin:-8px 0 12px}.form-help{font-size:12px;color:var(--muted);margin:-8px 0 14px}.honeypot{position:absolute!important;left:-10000px!important;width:1px!important;height:1px!important;overflow:hidden!important}
.support-hero{padding:8px 0 28px}.support-hero h1{font-size:44px;line-height:1.05;letter-spacing:-.04em;margin:8px 0 12px}.support-layout{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(280px,.7fr);gap:22px;align-items:start}.support-form-card{background:#fff;border:1px solid var(--line);border-radius:20px;padding:28px;box-shadow:var(--shadow-md)}.panel-heading-simple{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding-bottom:20px;border-bottom:1px solid var(--line);margin-bottom:22px}.panel-heading-simple h2{margin:0 0 4px;font-size:23px}.panel-heading-simple p{margin:0;color:var(--muted)}.response-badge{background:var(--blue-soft);color:var(--blue-dark);padding:6px 10px;border-radius:999px;font-size:12px;font-weight:800}.form-grid.two{display:grid;grid-template-columns:1fr 1fr;gap:16px}.consent-check{display:flex;align-items:flex-start;gap:10px;font-weight:500;color:#475467;margin:2px 0 20px}.consent-check input{width:18px;height:18px;margin:2px 0 0;flex:0 0 auto}.consent-check a{color:var(--blue);font-weight:700}.support-actions{display:flex;gap:12px;flex-wrap:wrap}.support-side{display:grid;gap:14px}.support-info-card{padding:22px;border-radius:18px;border:1px solid rgba(15,23,42,.08);box-shadow:var(--shadow-sm)}.support-info-card.blue{background:linear-gradient(135deg,#eff6ff,#dbeafe)}.support-info-card.green{background:linear-gradient(135deg,#ecfdf3,#d1fadf)}.support-info-card.violet{background:linear-gradient(135deg,#f5f3ff,#ede9fe)}.support-info-card h3{margin:12px 0 6px}.support-info-card p{margin:0;color:#475467}.support-info-icon{width:40px;height:40px;border-radius:11px;background:rgba(255,255,255,.75);display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:900;color:var(--blue)}
.legal-page{max-width:840px;margin:0 auto;background:#fff;border:1px solid var(--line);border-radius:20px;padding:42px 46px;box-shadow:var(--shadow-sm)}.legal-head{padding-bottom:24px;border-bottom:1px solid var(--line);margin-bottom:26px}.legal-head h1{font-size:42px;line-height:1.05;letter-spacing:-.04em;margin:8px 0}.legal-head p{margin:0;color:var(--muted)}.legal-intro{font-size:18px;line-height:1.65;color:#344054;background:linear-gradient(135deg,#eff6ff,#f8fafc);border:1px solid #dbeafe;border-radius:14px;padding:18px 20px;margin-bottom:30px}.legal-page section{padding:4px 0 16px}.legal-page h2{font-size:21px;margin:16px 0 8px}.legal-page p{color:#475467;line-height:1.72}.legal-page a,.link-button{color:var(--blue);font-weight:700}.link-button{padding:0;background:none;border:0;box-shadow:none;text-decoration:underline;display:inline}.link-button:hover{background:none;color:var(--blue-dark)}.legal-table,.cookie-table{display:grid;gap:10px}.legal-table>div,.cookie-table>div{display:grid;grid-template-columns:150px 1fr;gap:18px;padding:14px 0;border-bottom:1px solid var(--line)}.legal-table span,.cookie-table span{color:#475467}
.cookie-banner{position:fixed;z-index:1000;left:20px;right:20px;bottom:20px;max-width:980px;margin:auto;background:rgba(12,20,36,.98);color:#fff;border:1px solid rgba(255,255,255,.14);border-radius:18px;padding:20px 22px;box-shadow:0 24px 80px rgba(0,0,0,.38);backdrop-filter:blur(18px);display:none}.cookie-banner.is-open{display:block}.cookie-banner-grid{display:grid;grid-template-columns:1fr auto;gap:24px;align-items:center}.cookie-banner h2{margin:0 0 5px;font-size:18px}.cookie-banner p{margin:0;color:#cbd5e1;font-size:13px;line-height:1.55}.cookie-banner a{color:#bfdbfe}.cookie-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}.cookie-actions button{padding:9px 12px}.cookie-actions .btn-secondary{background:transparent;color:#fff;border-color:rgba(255,255,255,.25)}
.cookie-modal-backdrop{position:fixed;z-index:1100;inset:0;background:rgba(2,6,23,.64);display:none;align-items:center;justify-content:center;padding:20px}.cookie-modal-backdrop.is-open{display:flex}.cookie-modal{width:min(620px,100%);max-height:90vh;overflow:auto;background:#fff;border-radius:20px;box-shadow:0 34px 90px rgba(0,0,0,.35)}.cookie-modal-head{padding:24px 26px 18px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:18px}.cookie-modal-head h2{margin:0;font-size:24px}.cookie-close{background:#f2f4f7;color:#344054;width:36px;height:36px;padding:0;border-radius:50%}.cookie-close:hover{background:#e4e7ec}.cookie-modal-body{padding:8px 26px 4px}.cookie-option{display:grid;grid-template-columns:1fr auto;gap:18px;padding:17px 0;border-bottom:1px solid var(--line)}.cookie-option h3{margin:0 0 4px;font-size:16px}.cookie-option p{margin:0;color:var(--muted);font-size:13px}.cookie-switch{width:46px!important;height:24px!important;margin:2px 0!important;accent-color:var(--blue)}.cookie-modal-actions{padding:20px 26px 24px;display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap}.cookie-required{font-size:12px;font-weight:800;color:var(--green);padding-top:4px}
.admin-ticket-list{display:grid;gap:10px}.admin-ticket-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto auto;gap:18px;align-items:center;padding:16px 18px;background:#fff;border:1px solid var(--line);border-radius:14px;text-decoration:none;box-shadow:var(--shadow-sm)}.admin-ticket-row:hover{border-color:#b8cdf6}.admin-ticket-row>div:first-child{display:flex;flex-direction:column;min-width:0}.admin-ticket-row small{color:var(--muted)}.ticket-ref{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--blue);font-weight:850}.ticket-category,.ticket-status{padding:5px 8px;border-radius:999px;font-size:12px;font-weight:800;background:#f2f4f7;color:#475467}.status-new{background:#eff6ff;color:#1d4ed8}.status-open{background:#ecfdf3;color:#067647}.status-waiting{background:#fff7ed;color:#9a3412}.status-resolved,.status-closed{background:#f2f4f7;color:#475467}.admin-ticket-row time{color:var(--muted);font-size:12px}.filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:18px}.filter-bar select{width:auto;margin:0}.ticket-detail-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:22px}.ticket-detail-head h1{font-size:34px;line-height:1.12;letter-spacing:-.03em;margin:6px 0}.ticket-detail-head p{margin:0;color:var(--muted)}.ticket-message .ticket-meta{display:flex;gap:12px;color:var(--muted);font-size:13px;margin:8px 0 20px}.message-body{white-space:normal;background:#f8fafc;border:1px solid var(--line);border-radius:12px;padding:18px;line-height:1.7;margin-bottom:18px}
@media(max-width:800px){.support-layout{grid-template-columns:1fr}.form-grid.two{grid-template-columns:1fr}.legal-page{padding:28px 22px}.legal-table>div,.cookie-table>div{grid-template-columns:1fr;gap:4px}.cookie-banner-grid{grid-template-columns:1fr}.cookie-actions{justify-content:flex-start}.admin-ticket-row{grid-template-columns:1fr auto}.admin-ticket-row .ticket-category,.admin-ticket-row time{display:none}.ticket-detail-head{flex-direction:column}}
</style>
@stack('head')
</head>
@@ -160,9 +178,14 @@
<a href="{{ route('home') }}">Product</a>
<a href="{{ route('buy') }}">Pricing</a>
<a href="{{ route('support') }}">Support</a>
<a href="{{ route('privacy') }}">Privacy</a>
@auth
@if(auth()->user()->hasVerifiedEmail())<a href="{{ route('portal.index') }}">My licences</a>@endif
@if(auth()->user()->is_admin)<a href="{{ route('admin.index') }}">Admin</a>@endif
@if(auth()->user()->is_admin)
@php($supportUnread = auth()->user()->unreadNotifications()->where('type', \App\Notifications\NewSupportTicket::class)->count())
<a href="{{ route('admin.index') }}">Admin</a>
<a href="{{ route('admin.support.index') }}">Support@if($supportUnread)<span class="nav-alert-count">{{ $supportUnread }}</span>@endif</a>
@endif
@endauth
</div>
<div class="nav-account">
@@ -197,9 +220,82 @@
<a href="{{ route('home') }}">Product</a>
<a href="{{ route('buy') }}">Pricing</a>
<a href="{{ route('support') }}">Support</a>
<a href="{{ route('privacy') }}">Privacy</a>
<a href="{{ route('terms') }}">Terms</a>
<a href="{{ route('cookies') }}">Cookies</a>
<button type="button" class="footer-cookie-link" data-cookie-settings>Cookie settings</button>
@auth @if(auth()->user()->hasVerifiedEmail())<a href="{{ route('portal.index') }}">Licence portal</a>@endif @else<a href="{{ route('login') }}">Sign in</a>@endauth
</div>
</div>
</footer>
<div class="cookie-banner" id="cookieBanner" role="region" aria-label="Cookie consent">
<div class="cookie-banner-grid">
<div>
<h2>Your privacy choices</h2>
<p>We use necessary cookies for security, sign-in and licence services. Optional cookies stay off unless you choose them. Read our <a href="{{ route('cookies') }}">Cookie Policy</a>.</p>
</div>
<div class="cookie-actions">
<button type="button" class="btn btn-secondary" id="cookieReject">Reject non-essential</button>
<button type="button" class="btn btn-secondary" data-cookie-settings>Cookie settings</button>
<button type="button" class="btn btn-primary" id="cookieAccept">Accept all</button>
</div>
</div>
</div>
<div class="cookie-modal-backdrop" id="cookieModal" role="presentation">
<section class="cookie-modal" role="dialog" aria-modal="true" aria-labelledby="cookieModalTitle">
<div class="cookie-modal-head"><div><span class="eyebrow">Privacy controls</span><h2 id="cookieModalTitle">Cookie settings</h2></div><button type="button" class="cookie-close" id="cookieClose" aria-label="Close cookie settings">×</button></div>
<div class="cookie-modal-body">
<div class="cookie-option"><div><h3>Necessary</h3><p>Required for security, authentication, sessions and remembering this choice.</p></div><span class="cookie-required">Always on</span></div>
<label class="cookie-option"><div><h3>Preferences</h3><p>Allows non-essential interface preferences to be remembered.</p></div><input class="cookie-switch" type="checkbox" id="cookiePreferences"></label>
<label class="cookie-option"><div><h3>Analytics</h3><p>Helps understand site usage. No analytics is loaded unless this is enabled.</p></div><input class="cookie-switch" type="checkbox" id="cookieAnalytics"></label>
<label class="cookie-option"><div><h3>Marketing</h3><p>Allows optional marketing technologies. None is loaded unless this is enabled.</p></div><input class="cookie-switch" type="checkbox" id="cookieMarketing"></label>
</div>
<div class="cookie-modal-actions"><button type="button" class="btn btn-secondary" id="cookieRejectModal">Reject non-essential</button><button type="button" class="btn btn-primary" id="cookieSave">Save choices</button></div>
</section>
</div>
<script>
(() => {
const COOKIE_NAME = 'rsync_cookie_consent_v{{ config('rsync-studio.privacy.cookie_version', '1') }}';
const banner = document.getElementById('cookieBanner');
const modal = document.getElementById('cookieModal');
const prefs = document.getElementById('cookiePreferences');
const analytics = document.getElementById('cookieAnalytics');
const marketing = document.getElementById('cookieMarketing');
const read = () => {
const match = document.cookie.split('; ').find(row => row.startsWith(COOKIE_NAME + '='));
if (!match) return null;
try { return JSON.parse(decodeURIComponent(match.substring(COOKIE_NAME.length + 1))); } catch (_) { return null; }
};
const write = (value) => {
const secure = location.protocol === 'https:' ? '; Secure' : '';
document.cookie = COOKIE_NAME + '=' + encodeURIComponent(JSON.stringify(value)) + '; Max-Age=31536000; Path=/; SameSite=Lax' + secure;
window.rsyncCookieConsent = value;
window.dispatchEvent(new CustomEvent('rsync:cookie-consent', {detail:value}));
banner.classList.remove('is-open'); modal.classList.remove('is-open'); document.body.style.overflow = '';
};
const reject = () => write({necessary:true,preferences:false,analytics:false,marketing:false,updatedAt:new Date().toISOString()});
const open = () => { const current = read() || {}; prefs.checked=!!current.preferences; analytics.checked=!!current.analytics; marketing.checked=!!current.marketing; modal.classList.add('is-open'); document.body.style.overflow='hidden'; };
const close = () => { modal.classList.remove('is-open'); document.body.style.overflow=''; };
document.querySelectorAll('[data-cookie-settings]').forEach(el => el.addEventListener('click', open));
document.getElementById('cookieAccept').addEventListener('click', () => write({necessary:true,preferences:true,analytics:true,marketing:true,updatedAt:new Date().toISOString()}));
document.getElementById('cookieReject').addEventListener('click', reject);
document.getElementById('cookieRejectModal').addEventListener('click', reject);
document.getElementById('cookieSave').addEventListener('click', () => write({necessary:true,preferences:prefs.checked,analytics:analytics.checked,marketing:marketing.checked,updatedAt:new Date().toISOString()}));
document.getElementById('cookieClose').addEventListener('click', close);
modal.addEventListener('click', e => { if (e.target === modal) close(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.classList.contains('is-open')) close(); });
const current = read();
window.rsyncCookieConsent = current || {necessary:true,preferences:false,analytics:false,marketing:false};
if (!current) banner.classList.add('is-open');
})();
</script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
@extends('layouts.app', ['title' => 'Cookie Policy — Rsync Studio'])
@section('content')
<article class="legal-page">
<header class="legal-head"><span class="eyebrow">Privacy controls</span><h1>Cookie Policy</h1><p>Last updated 9 September 2026</p></header>
<div class="legal-intro">Rsync Studio uses a small number of cookies or equivalent browser storage items. Optional categories are off by default until you consent.</div>
<section><h2>Necessary</h2><p>These are required for security and core site features and cannot be disabled through Cookie Settings.</p><div class="cookie-table"><div><strong>Session cookie</strong><span>Keeps you signed in and maintains the secure server session. Session or configured server lifetime.</span></div><div><strong>XSRF / CSRF protection</strong><span>Helps protect forms and authenticated actions against cross-site request forgery. Session or configured framework lifetime.</span></div><div><strong>Cookie consent preference</strong><span>Stores the categories you selected so we do not ask on every page. Up to 12 months.</span></div></div></section>
<section><h2>Preferences</h2><p>Preference storage is optional and disabled unless you enable it. It may be used for non-essential interface choices in future versions of the site.</p></section>
<section><h2>Analytics</h2><p>Analytics is currently disabled by default. If analytics tooling is added, it must only load after you enable Analytics in Cookie Settings and this policy must be updated to identify the provider and retention period.</p></section>
<section><h2>Marketing</h2><p>Marketing storage is currently disabled by default. Marketing or advertising technology must not load before you expressly enable this category.</p></section>
<section><h2>Paddle checkout</h2><p>When you choose to start checkout, Paddle's checkout is loaded to provide payment and subscription functionality. Paddle may use storage that is necessary to provide checkout, prevent fraud and comply with payment obligations. Paddle provides its own privacy and cookie information for its service.</p></section>
<section><h2>Change your choice</h2><p>You can revisit your choice at any time.</p><button type="button" class="btn btn-secondary" data-cookie-settings>Open Cookie Settings</button></section>
</article>
@endsection
+39
View File
@@ -0,0 +1,39 @@
@extends('layouts.app', ['title' => 'Privacy Policy — Rsync Studio'])
@section('content')
@php
$legal = config('rsync-studio.legal');
$retention = config('rsync-studio.privacy.support_retention_days', 730);
@endphp
<article class="legal-page">
<header class="legal-head">
<span class="eyebrow">Privacy</span>
<h1>Privacy Policy</h1>
<p>Last updated 9 September 2026 · Policy version {{ config('rsync-studio.privacy.policy_version') }}</p>
</header>
<div class="legal-intro">{{ $legal['controller_name'] }} is the data controller for personal data processed through the Rsync Studio website, licence portal and support service. We process only the information needed to sell, license, secure and support Rsync Studio.</div>
<section><h2>Information we process</h2><p>We may process your name, email address, account credentials in hashed form, licence and activation identifiers, subscription and billing status, support requests, and technical server logs required to operate and secure the service. Rsync Studio does not send the files you sync, SSH passwords or private SSH keys to the licensing service.</p></section>
<section><h2>Payments</h2><p>Payments and subscription checkout are handled by Paddle. Payment-card details are entered into Paddle's checkout and are not stored by Rsync Studio. We receive identifiers and subscription information needed to provision and manage your licence. Paddle acts independently for parts of its payment and tax processing and provides its own privacy information.</p></section>
<section><h2>Why we use your information</h2><div class="legal-table"><div><strong>Contract</strong><span>Creating your account, providing licences, validating activations, managing subscriptions and delivering support you request.</span></div><div><strong>Legitimate interests</strong><span>Securing the service, preventing abuse, troubleshooting faults and maintaining reliable operational records where those interests do not override your rights.</span></div><div><strong>Legal obligations</strong><span>Records we must keep for accounting, tax, fraud prevention or other applicable legal requirements.</span></div><div><strong>Consent</strong><span>Any optional analytics or marketing storage, if introduced, is disabled until you choose to enable it through Cookie Settings.</span></div></div></section>
<section><h2>Support requests</h2><p>Support requests are stored in our database and emailed to the configured support team. We retain resolved support tickets for up to {{ $retention }} days, unless a longer period is necessary for an unresolved dispute, security matter or legal obligation. A scheduled retention process removes eligible resolved tickets.</p></section>
<section><h2>Licence and account records</h2><p>Licence, activation and subscription records are kept while your subscription or licence relationship is active and for a reasonable period afterwards where required for licence recovery, fraud prevention, accounting, dispute handling and legal obligations. We minimise retained data and do not use licence validation to inspect your synced files.</p></section>
<section><h2>Who receives data</h2><p>Information may be processed by service providers that are necessary to run Rsync Studio, such as Paddle for checkout and subscription processing, our hosting/infrastructure providers, and email delivery infrastructure. We limit disclosure to what is necessary for the relevant service and require appropriate safeguards where applicable.</p></section>
<section><h2>International transfers</h2><p>Some service providers may process information outside the UK or EEA. Where data-protection law requires it, appropriate transfer mechanisms and safeguards must be used by the relevant controller or processor.</p></section>
<section><h2>Cookies and local storage</h2><p>Necessary cookies are used for security, authentication, sessions and your privacy preferences. Optional analytics and marketing categories default to off and must not be loaded before consent. See our <a href="{{ route('cookies') }}">Cookie Policy</a> or use <button type="button" class="link-button" data-cookie-settings>Cookie Settings</button>.</p></section>
<section><h2>Your rights</h2><p>Depending on the circumstances, you may have rights to access, correct, erase, restrict or object to processing of your personal data, request portability, and withdraw consent at any time where processing relies on consent. You also have the right to complain to the UK Information Commissioner's Office or your applicable supervisory authority.</p><p>Use the <a href="{{ route('support') }}">support form</a> and choose “Privacy / data request”. We may need to verify your identity before acting on a request.</p></section>
<section><h2>Security</h2><p>We use access controls, TLS, password hashing, CSRF protection, rate limiting and restricted administrative access. No internet service can guarantee absolute security, so we also minimise the personal data we collect.</p></section>
<section><h2>Contact</h2><p>Controller: <strong>{{ $legal['controller_name'] }}</strong><br>Country: {{ $legal['country'] }}<br>Privacy contact: <a href="mailto:{{ $legal['privacy_email'] }}">{{ $legal['privacy_email'] }}</a></p></section>
</article>
@endsection
+19
View File
@@ -0,0 +1,19 @@
@extends('layouts.app', ['title' => 'Terms of Service — Rsync Studio'])
@section('content')
@php($legal = config('rsync-studio.legal'))
<article class="legal-page">
<header class="legal-head"><span class="eyebrow">Legal</span><h1>Terms of Service</h1><p>Last updated 9 September 2026</p></header>
<div class="legal-intro">These terms govern your use of the Rsync Studio website, licence portal, subscription service and macOS application.</div>
<section><h2>1. Licence plans</h2><p>Personal permits the number of active Mac installations shown for that plan at purchase. Professional permits the number shown for Professional. A licence is for your use and may not be shared, resold or used to bypass activation limits.</p></section>
<section><h2>2. Trials and subscriptions</h2><p>Where offered, the trial length and renewal terms are shown at checkout. Subscription billing, cancellation, tax and payment processing are handled through Paddle. Your subscription continues until cancelled in accordance with the checkout terms. Changes between Personal and Professional may be prorated by Paddle.</p></section>
<section><h2>3. Activation</h2><p>Rsync Studio uses a licence service to activate permitted Macs and periodically confirm that an activation remains valid. A reasonable offline grace period may be provided, but continued use after a subscription or activation is revoked is not authorised.</p></section>
<section><h2>4. Your rsync jobs</h2><p>You are responsible for choosing correct source and destination paths, permissions, credentials, rsync options and deletion behaviour. Dry Run is provided to help review operations before execution, but you remain responsible for backups and for verifying important transfers.</p></section>
<section><h2>5. Acceptable use</h2><p>You must not attempt to defeat licensing controls, interfere with the service, probe accounts you do not own, distribute malware, or use Rsync Studio in violation of applicable law or third-party rights.</p></section>
<section><h2>6. Updates and availability</h2><p>We may update the application, website and licensing service to improve security, compatibility or functionality. We aim to provide a reliable service but do not guarantee uninterrupted availability.</p></section>
<section><h2>7. Consumer rights</h2><p>Nothing in these terms excludes rights that cannot lawfully be excluded, including applicable UK consumer rights. Where mandatory law gives you stronger protections, that law prevails.</p></section>
<section><h2>8. Liability</h2><p>To the extent permitted by law, we are not responsible for indirect or consequential loss arising from incorrect sync configuration, deleted destination data, unavailable third-party systems or failures outside our reasonable control. This does not limit liability that cannot lawfully be limited.</p></section>
<section><h2>9. Privacy</h2><p>Our processing of personal data is described in the <a href="{{ route('privacy') }}">Privacy Policy</a> and <a href="{{ route('cookies') }}">Cookie Policy</a>.</p></section>
<section><h2>10. Contact</h2><p>These terms are provided by {{ $legal['controller_name'] }} in {{ $legal['country'] }}. Questions can be submitted through <a href="{{ route('support') }}">Rsync Studio Support</a>.</p></section>
</article>
@endsection
+6 -6
View File
@@ -39,18 +39,18 @@
<div class="app-content">
<div class="app-toolbar"><strong>Sync jobs</strong><span class="sync-all">Sync All</span></div>
<div class="mock-job">
<div class="mock-job-top"><strong>Website Deploy</strong><span class="job-state state-ready">Ready</span></div>
<p>MacBook → pegasus.local · SSH</p>
<div class="mock-job-top"><strong>Developer</strong><span class="job-state state-ready">Ready</span></div>
<p>Developer folder → remote server · SSH</p>
<div class="mock-progress"><span style="width:100%"></span></div>
</div>
<div class="mock-job">
<div class="mock-job-top"><strong>Documents Backup</strong><span class="job-state state-scheduled">22:00</span></div>
<p>Documents → Backup SSD · Daily</p>
<div class="mock-job-top"><strong>Web Sites</strong><span class="job-state state-scheduled">22:00</span></div>
<p>Sites folder → remote server · Daily</p>
<div class="mock-progress"><span style="width:72%"></span></div>
</div>
<div class="mock-job">
<div class="mock-job-top"><strong>Projects to Server</strong><span class="job-state state-watching">Watching</span></div>
<p>Developer → pegasus.local · File watch</p>
<div class="mock-job-top"><strong>Project Watch</strong><span class="job-state state-watching">Watching</span></div>
<p>Project folder · File watch</p>
<div class="mock-progress"><span style="width:44%"></span></div>
</div>
<div class="app-statusbar"><span>Last run: Success</span><span>rsync 3.4.1</span></div>
+99 -2
View File
@@ -1,3 +1,100 @@
@extends('layouts.app') @section('content')
<div class="card"><h1>Rsync Studio Support</h1><p>For licence recovery, create or sign in to your portal account using the same email address used at checkout.</p><p><a class="btn" href="{{ route('portal.index') }}">Open licence portal</a></p><p class="muted">For activation problems, include the app version and the exact error message. Never send SSH passwords, private keys, or your full licence token.</p></div>
@extends('layouts.app', [
'title' => 'Support — Rsync Studio',
'description' => 'Contact Rsync Studio support for technical, licence, billing and privacy enquiries.'
])
@section('content')
<section class="support-hero">
<span class="eyebrow">Support</span>
<h1>How can we help?</h1>
<p class="lead">Send us the details below. Your request is saved securely, assigned a reference and emailed to the support team.</p>
</section>
<div class="support-layout">
<section class="support-form-card">
<div class="panel-heading-simple">
<div>
<h2>Contact support</h2>
<p>We will reply to the email address you provide.</p>
</div>
<span class="response-badge">Support ticket</span>
</div>
<form method="post" action="{{ route('support.store') }}" class="support-form">
@csrf
<div class="honeypot" aria-hidden="true">
<label for="company_website">Company website</label>
<input id="company_website" name="company_website" type="text" tabindex="-1" autocomplete="off">
</div>
<div class="form-grid two">
<div>
<label for="name">Name</label>
<input id="name" name="name" value="{{ old('name', auth()->user()?->name) }}" autocomplete="name" required maxlength="120">
@error('name')<div class="field-error">{{ $message }}</div>@enderror
</div>
<div>
<label for="email">Email</label>
<input id="email" type="email" name="email" value="{{ old('email', auth()->user()?->email) }}" autocomplete="email" required maxlength="254">
@error('email')<div class="field-error">{{ $message }}</div>@enderror
</div>
</div>
<div class="form-grid two">
<div>
<label for="category">Category</label>
<select id="category" name="category" required>
@foreach($categories as $value => $label)
<option value="{{ $value }}" @selected(old('category') === $value)>{{ $label }}</option>
@endforeach
</select>
@error('category')<div class="field-error">{{ $message }}</div>@enderror
</div>
<div>
<label for="subject">Subject</label>
<input id="subject" name="subject" value="{{ old('subject') }}" required maxlength="180" placeholder="Briefly describe the issue">
@error('subject')<div class="field-error">{{ $message }}</div>@enderror
</div>
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="8" required maxlength="10000" placeholder="Tell us what happened, what you expected, your macOS version and Rsync Studio version where relevant.">{{ old('message') }}</textarea>
<div class="form-help">Do not include passwords, private SSH keys or your full licence token.</div>
@error('message')<div class="field-error">{{ $message }}</div>@enderror
</div>
<label class="consent-check">
<input type="checkbox" name="privacy_notice" value="1" @checked(old('privacy_notice')) required>
<span>I have read the <a href="{{ route('privacy') }}" target="_blank" rel="noopener">Privacy Policy</a> and understand that my details will be used to respond to this request.</span>
</label>
@error('privacy_notice')<div class="field-error">{{ $message }}</div>@enderror
<div class="support-actions">
<button type="submit" class="btn btn-primary">Send support request</button>
@auth
@if(auth()->user()->hasVerifiedEmail())<a class="btn btn-secondary" href="{{ route('portal.index') }}">Open licence portal</a>@endif
@endauth
</div>
</form>
</section>
<aside class="support-side">
<article class="support-info-card blue">
<div class="support-info-icon">◎</div>
<h3>Licence &amp; activation</h3>
<p>Include the last four characters of your licence key and the exact activation message.</p>
</article>
<article class="support-info-card green">
<div class="support-info-icon">✓</div>
<h3>Useful diagnostics</h3>
<p>Include the Rsync Studio version, macOS version and the relevant History error output.</p>
</article>
<article class="support-info-card violet">
<div class="support-info-icon">⌁</div>
<h3>Privacy requests</h3>
<p>Choose “Privacy / data request” for access, correction, deletion, restriction or objection requests.</p>
</article>
</aside>
</div>
@endsection
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Schedule;
// Removes resolved support requests after the configured retention period.
Schedule::command('model:prune')->dailyAt('03:20')->withoutOverlapping();
+8 -2
View File
@@ -1,5 +1,7 @@
<?php
use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Admin\SupportTicketController;
use App\Http\Controllers\Support\SupportController;
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\EmailVerificationController;
use App\Http\Controllers\Auth\PasswordController;
@@ -10,8 +12,12 @@ use App\Http\Middleware\AdminOnly;
use Illuminate\Support\Facades\Route;
Route::get('/', fn()=>view('portal.welcome'))->name('home');
Route::get('/buy', CheckoutController::class)->name('buy');
Route::get('/support', fn()=>view('support'))->name('support');
Route::get('/support', [SupportController::class, 'create'])->name('support');
Route::post('/support', [SupportController::class, 'store'])->middleware('throttle:5,10')->name('support.store');
Route::view('/privacy', 'legal.privacy')->name('privacy');
Route::view('/terms', 'legal.terms')->name('terms');
Route::view('/cookies', 'legal.cookies')->name('cookies');
Route::middleware('guest')->group(function(){Route::get('/login',[AuthenticatedSessionController::class,'create'])->name('login');Route::post('/login',[AuthenticatedSessionController::class,'store'])->middleware('throttle:login');Route::get('/register',[RegisteredUserController::class,'create'])->name('register');Route::post('/register',[RegisteredUserController::class,'store'])->middleware('throttle:registration');Route::get('/forgot-password',[PasswordController::class,'requestForm'])->name('password.request');Route::post('/forgot-password',[PasswordController::class,'email'])->name('password.email')->middleware('throttle:5,1');Route::get('/reset-password/{token}',[PasswordController::class,'resetForm'])->name('password.reset');Route::post('/reset-password',[PasswordController::class,'reset'])->name('password.update');});
Route::middleware('auth')->group(function(){Route::post('/logout',[AuthenticatedSessionController::class,'destroy'])->name('logout');Route::get('/verify-email',[EmailVerificationController::class,'notice'])->name('verification.notice');Route::get('/verify-email/{id}/{hash}',[EmailVerificationController::class,'verify'])->middleware(['signed','throttle:6,1'])->name('verification.verify');Route::post('/email/verification-notification',[EmailVerificationController::class,'resend'])->middleware('throttle:6,1')->name('verification.send');});
Route::middleware(['auth','verified'])->prefix('portal')->name('portal.')->group(function(){Route::get('/',[PortalController::class,'index'])->name('index');Route::get('/licences/{license}',[PortalController::class,'show'])->name('show');Route::post('/licences/{license}/upgrade',[PortalController::class,'upgrade'])->middleware('throttle:5,1')->name('upgrade');Route::delete('/licences/{license}/subscription',[PortalController::class,'cancel'])->middleware('throttle:5,1')->name('cancel');Route::post('/licences/{license}/resend',[PortalController::class,'resend'])->middleware('throttle:3,10')->name('resend');Route::delete('/licences/{license}/activations/{activation}',[PortalController::class,'deactivate'])->name('deactivate');});
Route::middleware(['auth','verified',AdminOnly::class])->prefix('admin')->name('admin.')->group(function(){Route::get('/',[AdminController::class,'index'])->name('index');Route::get('/licences',[AdminController::class,'licenses'])->name('licenses');Route::get('/licences/{license}',[AdminController::class,'show'])->name('license');Route::post('/licences/{license}/revoke',[AdminController::class,'revoke'])->name('revoke');Route::post('/licences/{license}/restore',[AdminController::class,'restore'])->name('restore');});
Route::middleware(['auth','verified',AdminOnly::class])->prefix('admin')->name('admin.')->group(function(){Route::get('/',[AdminController::class,'index'])->name('index');Route::get('/licences',[AdminController::class,'licenses'])->name('licenses');Route::get('/licences/{license}',[AdminController::class,'show'])->name('license');Route::post('/licences/{license}/revoke',[AdminController::class,'revoke'])->name('revoke');Route::post('/licences/{license}/restore',[AdminController::class,'restore'])->name('restore');Route::get('/support',[SupportTicketController::class,'index'])->name('support.index');Route::get('/support/{ticket}',[SupportTicketController::class,'show'])->name('support.show');Route::patch('/support/{ticket}',[SupportTicketController::class,'update'])->name('support.update');});