From 2078dbf6371df4583585c6ae1ae5428c4e0a3b5c Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 9 Sep 2026 16:56:31 +0100 Subject: [PATCH] new front end --- .../Admin/SupportTicketController.php | 59 ++++++++++ .../Controllers/Support/SupportController.php | 92 ++++++++++++++++ app/Models/SupportTicket.php | 39 +++++++ app/Notifications/NewSupportTicket.php | 43 ++++++++ config/rsync-studio.php | 16 +++ database/database.sqlite | Bin 147456 -> 184320 bytes ...eate_support_tickets_and_notifications.php | 47 ++++++++ resources/views/admin/index.blade.php | 7 +- resources/views/admin/support/index.blade.php | 24 +++++ resources/views/admin/support/show.blade.php | 14 +++ resources/views/layouts/app.blade.php | 102 +++++++++++++++++- resources/views/legal/cookies.blade.php | 16 +++ resources/views/legal/privacy.blade.php | 39 +++++++ resources/views/legal/terms.blade.php | 19 ++++ resources/views/portal/welcome.blade.php | 12 +-- resources/views/support.blade.php | 101 ++++++++++++++++- routes/console.php | 6 ++ routes/web.stage5.php | 10 +- 18 files changed, 632 insertions(+), 14 deletions(-) create mode 100644 app/Http/Controllers/Admin/SupportTicketController.php create mode 100644 app/Http/Controllers/Support/SupportController.php create mode 100644 app/Models/SupportTicket.php create mode 100644 app/Notifications/NewSupportTicket.php create mode 100644 database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php create mode 100644 resources/views/admin/support/index.blade.php create mode 100644 resources/views/admin/support/show.blade.php create mode 100644 resources/views/legal/cookies.blade.php create mode 100644 resources/views/legal/privacy.blade.php create mode 100644 resources/views/legal/terms.blade.php create mode 100644 routes/console.php diff --git a/app/Http/Controllers/Admin/SupportTicketController.php b/app/Http/Controllers/Admin/SupportTicketController.php new file mode 100644 index 0000000..99484af --- /dev/null +++ b/app/Http/Controllers/Admin/SupportTicketController.php @@ -0,0 +1,59 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Support/SupportController.php b/app/Http/Controllers/Support/SupportController.php new file mode 100644 index 0000000..13e9bec --- /dev/null +++ b/app/Http/Controllers/Support/SupportController.php @@ -0,0 +1,92 @@ + [ + '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; + } +} diff --git a/app/Models/SupportTicket.php b/app/Models/SupportTicket.php new file mode 100644 index 0000000..f12c556 --- /dev/null +++ b/app/Models/SupportTicket.php @@ -0,0 +1,39 @@ + '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)); + } +} diff --git a/app/Notifications/NewSupportTicket.php b/app/Notifications/NewSupportTicket.php new file mode 100644 index 0000000..6f76b82 --- /dev/null +++ b/app/Notifications/NewSupportTicket.php @@ -0,0 +1,43 @@ +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, + ]; + } +} diff --git a/config/rsync-studio.php b/config/rsync-studio.php index 9a2b2b8..54c5b73 100644 --- a/config/rsync-studio.php +++ b/config/rsync-studio.php @@ -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'), diff --git a/database/database.sqlite b/database/database.sqlite index 3f4a46a913ac1d24a3f7d15146140387bc240208..234116b250348b21771c86d51126e65543095782 100644 GIT binary patch delta 2540 zcmds2&2Jk;6yLEoyZ*qt2@tjsB^hsNY{w3AW9kxB0@C0EQUejnq2;nn?TNc&y=!*I zp*f(9Bd7ogsg_apNLBa;s4H1~2sj3g^p1qoKfvmt7aEmWf7RsPXpF3`tCb?8bn#<3&yC`RvjY^ zW}Al>!7b3Za851=hG9Cw^zIv5S7qANBp@OVO$CnPoBh z)K>U*cs=x6=yYhY`SLpWHOMlI|4RqF3sN>0tx_0sBVphYQnisNV&hTZ(&fJAks#X9DTe72 zytGC!yfzYpi8rx{^*mN8y81x{4^?2^sV#wNd&sH`dmRa)zfewml972SnFOD|K+q*^ zIMGmslidT@SUGn-2PcOc!%;HebUi0L1CeE^MV&SnF=VLf1Q)THg0vgD+p4dy)6U%u zkV#U?;G$vf$Z#8(`OC zegh`ydke6W4YqYS7Y$%0!kuMAM`#J(39m#hhQ1DkgP#U{ff5e_3*1@C@)^#qt|oZN zh=J-y?Fx7Z+@-U7APU(ja$;{j#!Imn_&D6IpC^K6u;8hkJmr~mbJhO@66*qViz5!C>DfXLZB8o-j?EMh zK{6!x3N2W-Xze1S`gQhIN7?{F*oBx_j&ssl%=u*<9J4)Xl~bN!k}6l=$;y}Q3if~~ zb!s_=D1D*)Z{*7!DX2aDdh+sRkCF8O;XCp`{;hm*?JM*DqhHy_(61OJhgG@sKF$-D zVs;5z7AoRL8PvU~#wenWKXjv&P1P_}GFCiw!z>}KyLubtciMVHZBB!oFBrPz=O+%!x~6(4Tlowz)x{*M(W VQFUYm{Lx%q2L}Lz8ld$+{2MU(aC`s& delta 240 zcmZozz}?WmIYC;mkAZ`y{NJ5PNH{ZF9B~AQt3VHb@ z3P2GN0bU@<#&?2&|1$q{{sew4zW;nDHVZ0j;oE#d&Rmd@t;t}T0V4~u0F&c%b^}I5 z7Diu&{D~8tr{^0m%1u8I!6-1@Hx;%PG&{%eII0 z63YY@UFIBSMW%gBI!ug=GZ;QH>|(IwR|HyIufQ(us?50EB!KZG=k|%Qj9VC47#Nrf frhCRSZr?ngc`MWO$h}PWH+^7H*v`_x_-8)=8~#2F diff --git a/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php b/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php new file mode 100644 index 0000000..a6223e3 --- /dev/null +++ b/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php @@ -0,0 +1,47 @@ +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'); + } +}; diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php index 8df543f..b0e4829 100644 --- a/resources/views/admin/index.blade.php +++ b/resources/views/admin/index.blade.php @@ -1 +1,6 @@ -@extends('layouts.app') @section('content')

Licensing administration

{{ $licenseCount }}

Licences

{{ $activeCount }}

Not revoked

{{ $activationCount }}

Active installations

{{ $userCount }}

Portal users

Manage licences@endsection +@extends('layouts.app') +@section('content') +
Admin

Licensing administration

Manage licences, activations and customer support.

+
{{ $licenseCount }}

Licences

{{ $activeCount }}

Not revoked

{{ $activationCount }}

Active installations

{{ $userCount }}

Portal users

+ +@endsection diff --git a/resources/views/admin/support/index.blade.php b/resources/views/admin/support/index.blade.php new file mode 100644 index 0000000..db55a42 --- /dev/null +++ b/resources/views/admin/support/index.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.app', ['title' => 'Support Inbox — Rsync Studio']) +@section('content') +
Admin

Support inbox

Customer, licence, billing and privacy requests.

+
+ + +
+ +{{ $tickets->links() }} +@endsection diff --git a/resources/views/admin/support/show.blade.php b/resources/views/admin/support/show.blade.php new file mode 100644 index 0000000..4248c64 --- /dev/null +++ b/resources/views/admin/support/show.blade.php @@ -0,0 +1,14 @@ +@extends('layouts.app', ['title' => $ticket->reference.' — Support']) +@section('content') +← Support inbox +
{{ $ticket->reference }}

{{ $ticket->subject }}

{{ $ticket->name }} · {{ $ticket->email }}

{{ ucfirst($ticket->status) }}
+
+

Request

{{ ucfirst($ticket->category) }}{{ $ticket->created_at->format('j M Y H:i') }}
{!! nl2br(e($ticket->message)) !!}
Reply by email
+ +
+@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index eaeb291..f7b16ec 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -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}} + @stack('head') @@ -160,9 +178,14 @@ Product Pricing Support + Privacy @auth @if(auth()->user()->hasVerifiedEmail())My licences@endif - @if(auth()->user()->is_admin)Admin@endif + @if(auth()->user()->is_admin) + @php($supportUnread = auth()->user()->unreadNotifications()->where('type', \App\Notifications\NewSupportTicket::class)->count()) + Admin + Support@if($supportUnread){{ $supportUnread }}@endif + @endif @endauth + + + + + + + + diff --git a/resources/views/legal/cookies.blade.php b/resources/views/legal/cookies.blade.php new file mode 100644 index 0000000..b46c1cb --- /dev/null +++ b/resources/views/legal/cookies.blade.php @@ -0,0 +1,16 @@ +@extends('layouts.app', ['title' => 'Cookie Policy — Rsync Studio']) + +@section('content') + +@endsection diff --git a/resources/views/legal/privacy.blade.php b/resources/views/legal/privacy.blade.php new file mode 100644 index 0000000..04866ae --- /dev/null +++ b/resources/views/legal/privacy.blade.php @@ -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 + +@endsection diff --git a/resources/views/legal/terms.blade.php b/resources/views/legal/terms.blade.php new file mode 100644 index 0000000..617f52f --- /dev/null +++ b/resources/views/legal/terms.blade.php @@ -0,0 +1,19 @@ +@extends('layouts.app', ['title' => 'Terms of Service — Rsync Studio']) + +@section('content') +@php($legal = config('rsync-studio.legal')) + +@endsection diff --git a/resources/views/portal/welcome.blade.php b/resources/views/portal/welcome.blade.php index 33a3e89..ef03706 100644 --- a/resources/views/portal/welcome.blade.php +++ b/resources/views/portal/welcome.blade.php @@ -39,18 +39,18 @@
Sync jobsSync All
-
Website DeployReady
-

MacBook → pegasus.local · SSH

+
DeveloperReady
+

Developer folder → remote server · SSH

-
Documents Backup22:00
-

Documents → Backup SSD · Daily

+
Web Sites22:00
+

Sites folder → remote server · Daily

-
Projects to ServerWatching
-

Developer → pegasus.local · File watch

+
Project WatchWatching
+

Project folder · File watch

Last run: Successrsync 3.4.1
diff --git a/resources/views/support.blade.php b/resources/views/support.blade.php index 0632136..aba46f0 100644 --- a/resources/views/support.blade.php +++ b/resources/views/support.blade.php @@ -1,3 +1,100 @@ -@extends('layouts.app') @section('content') -

Rsync Studio Support

For licence recovery, create or sign in to your portal account using the same email address used at checkout.

Open licence portal

For activation problems, include the app version and the exact error message. Never send SSH passwords, private keys, or your full licence token.

+@extends('layouts.app', [ + 'title' => 'Support — Rsync Studio', + 'description' => 'Contact Rsync Studio support for technical, licence, billing and privacy enquiries.' +]) + +@section('content') +
+ Support +

How can we help?

+

Send us the details below. Your request is saved securely, assigned a reference and emailed to the support team.

+
+ +
+
+
+
+

Contact support

+

We will reply to the email address you provide.

+
+ Support ticket +
+ +
+ @csrf + + +
+
+ + + @error('name')
{{ $message }}
@enderror +
+
+ + + @error('email')
{{ $message }}
@enderror +
+
+ +
+
+ + + @error('category')
{{ $message }}
@enderror +
+
+ + + @error('subject')
{{ $message }}
@enderror +
+
+ +
+ + +
Do not include passwords, private SSH keys or your full licence token.
+ @error('message')
{{ $message }}
@enderror +
+ + + @error('privacy_notice')
{{ $message }}
@enderror + +
+ + @auth + @if(auth()->user()->hasVerifiedEmail())Open licence portal@endif + @endauth +
+
+
+ + +
@endsection diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..b98b725 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,6 @@ +dailyAt('03:20')->withoutOverlapping(); diff --git a/routes/web.stage5.php b/routes/web.stage5.php index 779d0fe..9e285aa 100644 --- a/routes/web.stage5.php +++ b/routes/web.stage5.php @@ -1,5 +1,7 @@ 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');});