feat: Introduce a self-hosted mode, refactor email notifications to use Mailable classes with new templates, and update the project README.

This commit is contained in:
Paulo Castellano 2026-01-18 17:48:12 -03:00
parent af32361e89
commit a6517f4b5b
34 changed files with 1039 additions and 265 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
github: [trypost-it]

65
LICENSE.md Normal file
View file

@ -0,0 +1,65 @@
# TryPost License
## Functional Source License, Version 1.1, MIT
### Licensor
TryPost
### Software
TryPost - Social Media Scheduling Platform
### Use Limitation
You may not use the Software to provide a commercial service that allows third parties to schedule, manage, or publish content to social media platforms, where such service competes with or substitutes for TryPost or any product or service provided by Licensor.
### License Grant
Licensor hereby grants you the right to copy, modify, create derivative works, redistribute, and make non-production use of the Software. If your use of the Software does not violate the Use Limitation, Licensor hereby grants you the right to make production use of the Software.
### Permitted Uses
The following uses are expressly permitted:
1. **Personal Use**: Using TryPost for your own personal social media management
2. **Internal Business Use**: Using TryPost within your organization to manage your company's social media accounts
3. **Educational Use**: Using TryPost for learning, teaching, or research purposes
4. **Development**: Modifying, studying, or contributing to the TryPost codebase
5. **Self-Hosting**: Deploying TryPost on your own infrastructure for personal or internal business use
### Prohibited Uses
The following uses are expressly prohibited:
1. **Competing SaaS**: Offering TryPost (or a modified version) as a hosted service to third parties
2. **White-Label Services**: Rebranding and selling TryPost as your own product
3. **Managed Service Provider**: Operating TryPost as a service for multiple clients or customers
### Corollary
For clarity, the Use Limitation does not prohibit:
- Using TryPost to manage social media for your own business or clients (as a social media manager)
- Integrating TryPost into internal tools that are not offered as a competing service
- Contributing improvements back to the TryPost project
### Notice
You must ensure that anyone who receives a copy of any part of the Software from you also receives a copy of this License.
### No Other Rights
These terms do not grant you any right in any trademark or logo of Licensor or its affiliates.
### Patent
To the extent your use qualifies as a Permitted Use, Licensor grants you a license under any patent claims it can license that are necessarily infringed by the Software, to make, use, and distribute the Software.
### Disclaimer
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
Copyright (c) 2025 TryPost

202
README.md
View file

@ -1,15 +1,199 @@
<p align="center">
<img src="public/images/trypost/logo-dark.png" alt="TryPost" width="200">
</p>
## Migration with base seed
```sh
php artisan migrate:fresh --seed
<h1 align="center">TryPost</h1>
<p align="center">
<strong>The open-source social media scheduling platform</strong>
</p>
<p align="center">
Schedule, manage, and publish content to all your social media accounts from one place.
<br />
Self-hosted. Privacy-focused. No limits.
</p>
<p align="center">
<a href="https://github.com/trypost-it/trypost/stargazers"><img src="https://img.shields.io/github/stars/trypost-it/trypost" alt="Stars"></a>
<a href="https://github.com/trypost-it/trypost/blob/main/LICENSE.md"><img src="https://img.shields.io/badge/license-FSL-blue" alt="License"></a>
<a href="https://github.com/trypost-it/trypost/releases"><img src="https://img.shields.io/github/v/release/trypost-it/trypost" alt="Release"></a>
</p>
<p align="center">
<a href="#features">Features</a> •
<a href="#supported-platforms">Platforms</a> •
<a href="#quick-start">Quick Start</a> •
<a href="#self-hosting">Self-Hosting</a> •
<a href="#contributing">Contributing</a>
</p>
---
## Why TryPost?
Tired of paying expensive monthly fees for social media scheduling tools? Want full control over your data? TryPost is the solution.
- **100% Open Source** - Inspect the code, contribute, make it yours
- **Self-Hosted** - Your data stays on your servers
- **No Limits** - Schedule unlimited posts, connect unlimited accounts
- **Privacy First** - No tracking, no analytics, no data selling
## Features
- **Visual Calendar** - Drag and drop posts across your content calendar
- **Post Composer** - Create and preview posts for multiple platforms at once
- **Media Library** - Upload images and videos with automatic optimization
- **Team Collaboration** - Invite team members with role-based permissions
- **Workspaces** - Manage multiple brands or clients separately
- **Smart Scheduling** - Schedule posts for optimal engagement times
- **Multi-Platform Preview** - See exactly how your post will look on each platform
## Supported Platforms
<table>
<tr>
<td align="center"><img src="public/images/accounts/x.png" width="40"><br><b>X (Twitter)</b></td>
<td align="center"><img src="public/images/accounts/linkedin.png" width="40"><br><b>LinkedIn</b></td>
<td align="center"><img src="public/images/accounts/facebook.png" width="40"><br><b>Facebook</b></td>
<td align="center"><img src="public/images/accounts/instagram.png" width="40"><br><b>Instagram</b></td>
<td align="center"><img src="public/images/accounts/tiktok.png" width="40"><br><b>TikTok</b></td>
</tr>
<tr>
<td align="center"><img src="public/images/accounts/youtube.png" width="40"><br><b>YouTube</b></td>
<td align="center"><img src="public/images/accounts/pinterest.png" width="40"><br><b>Pinterest</b></td>
<td align="center"><img src="public/images/accounts/threads.png" width="40"><br><b>Threads</b></td>
<td align="center"><img src="public/images/accounts/bluesky.png" width="40"><br><b>Bluesky</b></td>
<td align="center"><img src="public/images/accounts/mastodon.png" width="40"><br><b>Mastodon</b></td>
</tr>
</table>
## Quick Start
### Requirements
- PHP 8.2+
- Node.js 18+
- MySQL 8.0+ or PostgreSQL 14+
- Redis
- Composer
### Installation
```bash
# Clone the repository
git clone https://github.com/trypost-it/trypost.git
cd trypost
# Install dependencies
composer install
npm install
# Setup environment
cp .env.example .env
php artisan key:generate
# Configure your database in .env, then run migrations
php artisan migrate --seed
# Build assets
npm run build
# Start the server
php artisan serve
```
# Start queue worker
```sh
Visit `http://localhost:8000` and create your account!
## Self-Hosting
TryPost is designed to be self-hosted. Enable self-hosted mode in your `.env`:
```env
SELF_HOSTED=true
```
This skips payment/subscription requirements and gives you full access.
### Running in Production
```bash
# Start the queue worker
php artisan horizon
# Or use the watcher for development
php artisan horizon:watch
```
# Share with Ngrok:
```sh
ngrok http --host-header=rewrite trypost.test:443
```
### Docker (Coming Soon)
```bash
docker-compose up -d
```
## Tech Stack
| Layer | Technology |
|-------|------------|
| **Backend** | Laravel 12, PHP 8.4 |
| **Frontend** | Vue 3, Inertia.js, TypeScript |
| **Styling** | Tailwind CSS 4 |
| **Database** | MySQL / PostgreSQL |
| **Queue** | Redis + Laravel Horizon |
| **Real-time** | Laravel Reverb |
## Roadmap
Check out our [public roadmap](https://github.com/orgs/trypost-it/projects/1) to see what's coming next and vote on features!
## Contributing
We love contributions! Whether it's:
- Bug reports
- Feature requests
- Documentation improvements
- Code contributions
### Development Setup
```bash
# Install dependencies
composer install
npm install
# Start development server
npm run dev
# Run tests
php artisan test
```
## Community
- [GitHub Discussions](https://github.com/trypost-it/trypost/discussions) - Ask questions, share ideas
- [GitHub Issues](https://github.com/trypost-it/trypost/issues) - Report bugs, request features
## License
TryPost is licensed under the [Functional Source License (FSL)](LICENSE.md).
**You can:**
- Use TryPost for personal or internal business use
- Self-host for your own social media management
- Modify and contribute to the codebase
**You cannot:**
- Offer TryPost as a competing SaaS product
- White-label and resell TryPost
---
<p align="center">
<strong>If TryPost helps you, please give us a star!</strong>
<br />
<br />
<a href="https://github.com/trypost-it/trypost">
<img src="https://img.shields.io/github/stars/trypost-it/trypost?style=social" alt="Star on GitHub">
</a>
</p>

View file

@ -6,26 +6,4 @@ enum Status: string
{
case Pending = 'pending';
case Accepted = 'accepted';
case Expired = 'expired';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Pending',
self::Accepted => 'Accepted',
self::Expired => 'Expired',
self::Cancelled => 'Cancelled',
};
}
public function color(): string
{
return match ($this) {
self::Pending => 'yellow',
self::Accepted => 'green',
self::Expired => 'gray',
self::Cancelled => 'red',
};
}
}

View file

@ -74,12 +74,24 @@ public function step2(Request $request): Response
}
/**
* Store step 2 and redirect to Stripe checkout.
* Store step 2 and redirect to Stripe checkout (or complete if self-hosted).
*/
public function storeStep2(Request $request): SymfonyResponse
public function storeStep2(Request $request): SymfonyResponse|RedirectResponse
{
$user = $request->user();
// Skip payment for self-hosted mode
if (config('trypost.self_hosted')) {
$user->update([
'setup' => Setup::Completed,
]);
session()->flash('flash.banner', 'Welcome to TryPost!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('calendar');
}
$user->update([
'setup' => Setup::Subscription,
]);

View file

@ -4,10 +4,11 @@
use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Http\Requests\StoreWorkspaceInviteRequest;
use App\Mail\WorkspaceInvite as WorkspaceInviteMail;
use App\Models\WorkspaceInvite;
use App\Notifications\WorkspaceInviteNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Inertia\Inertia;
use Inertia\Response;
@ -86,9 +87,12 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse
'role' => $request->role ?? WorkspaceRole::Member,
]);
$invite->notify(new WorkspaceInviteNotification($invite));
Mail::to($invite->email)->send(new WorkspaceInviteMail($invite));
return back()->with('success', 'Invite sent successfully!');
session()->flash('flash.banner', 'Invite sent successfully!');
session()->flash('flash.bannerStyle', 'success');
return back();
}
public function destroy(Request $request, WorkspaceInvite $invite): RedirectResponse
@ -105,23 +109,65 @@ public function destroy(Request $request, WorkspaceInvite $invite): RedirectResp
abort(404);
}
$invite->cancel();
$invite->delete();
return back()->with('success', 'Invite cancelled.');
session()->flash('flash.banner', 'Invite deleted.');
session()->flash('flash.bannerStyle', 'success');
return back();
}
public function show(Request $request, string $token): Response|RedirectResponse
{
$invite = WorkspaceInvite::where('token', $token)
->with(['workspace', 'inviter'])
->firstOrFail();
if (! $invite->isPending()) {
session()->flash('flash.banner', 'This invite is no longer valid.');
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('login');
}
$user = $request->user();
// Store token in session for after login/register
session(['pending_invite_token' => $token]);
return Inertia::render('invites/Accept', [
'invite' => [
'id' => $invite->id,
'token' => $invite->token,
'email' => $invite->email,
'role' => [
'value' => $invite->role->value,
'label' => $invite->role->label(),
],
'workspace' => [
'id' => $invite->workspace->id,
'name' => $invite->workspace->name,
],
'inviter' => [
'id' => $invite->inviter->id,
'name' => $invite->inviter->name,
'email' => $invite->inviter->email,
],
],
'isAuthenticated' => (bool) $user,
'userEmail' => $user?->email,
]);
}
public function accept(Request $request, string $token): RedirectResponse
{
$invite = WorkspaceInvite::where('token', $token)->firstOrFail();
if (! $invite->isValid()) {
if ($invite->isExpired()) {
return redirect()->route('dashboard')
->withErrors(['invite' => 'This invite has expired.']);
}
if (! $invite->isPending()) {
session()->flash('flash.banner', 'This invite is no longer valid.');
session()->flash('flash.bannerStyle', 'danger');
return redirect()->route('dashboard')
->withErrors(['invite' => 'This invite is no longer valid.']);
return redirect()->route('login');
}
$user = $request->user();
@ -129,25 +175,28 @@ public function accept(Request $request, string $token): RedirectResponse
if (! $user) {
session(['pending_invite_token' => $token]);
return redirect()->route('login')
->with('message', 'Please log in to accept the invite.');
return redirect()->route('login');
}
// Clear the pending token
session()->forget('pending_invite_token');
if ($invite->workspace->hasMember($user)) {
// Switch to this workspace
$user->switchWorkspace($invite->workspace);
return redirect()->route('calendar')
->with('message', 'You are already a member of this workspace.');
session()->flash('flash.banner', 'You are already a member of this workspace.');
session()->flash('flash.bannerStyle', 'info');
return redirect()->route('calendar');
}
$invite->accept($user);
// Switch to the new workspace
$user->switchWorkspace($invite->workspace);
return redirect()->route('calendar')
->with('success', 'You are now a member of the workspace!');
session()->flash('flash.banner', 'You are now a member of the workspace!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('calendar');
}
public function removeMember(Request $request, string $userId): RedirectResponse
@ -166,6 +215,9 @@ public function removeMember(Request $request, string $userId): RedirectResponse
$workspace->members()->detach($userId);
return back()->with('success', 'Member removed successfully.');
session()->flash('flash.banner', 'Member removed successfully.');
session()->flash('flash.bannerStyle', 'success');
return back();
}
}

View file

@ -15,6 +15,11 @@ class EnsureSubscribed
*/
public function handle(Request $request, Closure $next): Response
{
// Skip subscription check for self-hosted mode
if (config('trypost.self_hosted')) {
return $next($request);
}
$user = $request->user();
if (! $user) {

View file

@ -49,6 +49,7 @@ public function share(Request $request): array
'flash' => $request->session()->get('flash', []),
'env' => config('app.env'),
'locale' => app()->getLocale(),
'selfHosted' => config('trypost.self_hosted'),
];
}
}

View file

@ -12,6 +12,11 @@ public function toResponse($request): Response
{
$user = $request->user();
// Check for pending invite token
if ($token = session('pending_invite_token')) {
return redirect()->route('invites.show', $token);
}
// Determine redirect based on setup status
$redirect = match ($user->setup) {
Setup::Completed => route('calendar'),

View file

@ -9,6 +9,11 @@ class RegisterResponse implements RegisterResponseContract
{
public function toResponse($request): Response
{
// Check for pending invite token
if ($token = session('pending_invite_token')) {
return redirect()->route('invites.show', $token);
}
return $request->wantsJson()
? response()->json(['two_factor' => false])
: redirect()->route('onboarding.step1');

View file

@ -0,0 +1,45 @@
<?php
namespace App\Mail;
use App\Models\WorkspaceInvite as WorkspaceInviteModel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class WorkspaceInvite extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public WorkspaceInviteModel $invite
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "You've been invited to join {$this->invite->workspace->name}",
);
}
public function content(): Content
{
return new Content(
view: 'mail.workspace-invite',
with: [
'title' => "You've been invited to join {$this->invite->workspace->name}",
'previewText' => "{$this->invite->inviter->name} has invited you to collaborate on TryPost.",
'invite' => $this->invite,
'url' => route('invites.show', $this->invite->token),
],
);
}
public function attachments(): array
{
return [];
}
}

View file

@ -27,7 +27,6 @@ class WorkspaceInvite extends Model
'token',
'role',
'status',
'expires_at',
'accepted_at',
];
@ -39,7 +38,6 @@ protected function casts(): array
return [
'role' => Role::class,
'status' => InviteStatus::class,
'expires_at' => 'datetime',
'accepted_at' => 'datetime',
];
}
@ -50,9 +48,6 @@ protected static function booted(): void
if (empty($invite->token)) {
$invite->token = Str::random(64);
}
if (empty($invite->expires_at)) {
$invite->expires_at = now()->addDays(7);
}
});
}
@ -71,26 +66,11 @@ public function scopePending(Builder $query): Builder
return $query->where('status', InviteStatus::Pending);
}
public function scopeValid(Builder $query): Builder
{
return $query->pending()->where('expires_at', '>', now());
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function isPending(): bool
{
return $this->status === InviteStatus::Pending;
}
public function isValid(): bool
{
return $this->isPending() && ! $this->isExpired();
}
public function accept(User $user): void
{
$this->workspace->members()->attach($user->id, [
@ -103,11 +83,6 @@ public function accept(User $user): void
]);
}
public function cancel(): void
{
$this->update(['status' => InviteStatus::Cancelled]);
}
public function routeNotificationForMail(): string
{
return $this->email;

View file

@ -1,63 +0,0 @@
<?php
namespace App\Notifications;
use App\Models\WorkspaceInvite;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class WorkspaceInviteNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public WorkspaceInvite $invite
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$acceptUrl = route('invites.accept', $this->invite->token);
return (new MailMessage)
->subject("Convite para o workspace {$this->invite->workspace->name}")
->greeting('Olá!')
->line("{$this->invite->inviter->name} convidou você para colaborar no workspace **{$this->invite->workspace->name}**.")
->line("Você foi convidado como **{$this->invite->role->label()}**.")
->action('Aceitar Convite', $acceptUrl)
->line('Este convite expira em 7 dias.')
->salutation('Obrigado por usar TryPost!');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'workspace_id' => $this->invite->workspace_id,
'workspace_name' => $this->invite->workspace->name,
'inviter_name' => $this->invite->inviter->name,
'role' => $this->invite->role->value,
];
}
}

View file

@ -135,10 +135,10 @@ protected function configureDefaults(): void
VerifyEmail::toMailUsing(function (User $user, string $url) {
return (new MailMessage)
->from(config('mail.from.address'), config('mail.from.name'))
->subject('Confirme o seu endereço de e-mail')
->subject('Verify your email address')
->view('mail.email-verification', [
'title' => 'Confirme o seu endereço de e-mail',
'previewText' => 'Por favor, confirme o seu endereço de e-mail.',
'title' => 'Verify your email address',
'previewText' => 'Please verify your email address.',
'user' => $user,
'url' => $url,
]);
@ -153,10 +153,10 @@ protected function configureDefaults(): void
return (new MailMessage)
->from(config('mail.from.address'), config('mail.from.name'))
->subject('Redefina sua senha')
->subject('Reset your password')
->view('mail.password-reset', [
'title' => 'Redefina sua senha',
'previewText' => 'Por favor, redefina sua senha.',
'title' => 'Reset your password',
'previewText' => 'Reset your password.',
'user' => $user,
'url' => $url,
]);

View file

@ -56,6 +56,7 @@ private function configureViews(): void
'canResetPassword' => Features::enabled(Features::resetPasswords()),
'canRegister' => Features::enabled(Features::registration()),
'status' => $request->session()->get('status'),
'email' => $request->query('email'),
]));
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/ResetPassword', [
@ -71,7 +72,9 @@ private function configureViews(): void
'status' => $request->session()->get('status'),
]));
Fortify::registerView(fn () => Inertia::render('auth/Register'));
Fortify::registerView(fn (Request $request) => Inertia::render('auth/Register', [
'email' => $request->query('email'),
]));
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/TwoFactorChallenge'));

View file

@ -2,6 +2,18 @@
return [
/*
|--------------------------------------------------------------------------
| Self-Hosted Mode
|--------------------------------------------------------------------------
|
| When enabled, the application runs in self-hosted mode which skips
| payment/subscription requirements during onboarding.
|
*/
'self_hosted' => env('SELF_HOSTED', true),
/*
|--------------------------------------------------------------------------
| Social Platforms

1
lang/php_en.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -7,29 +7,7 @@
<tr>
<td align="center" class="text-center text-zinc-600 text-xs p-6">
<p class="m-0 mb-2">
Enviado por <a href="https://clinyx.com.br" class="text-zinc-600">Clinyx</a> - Sistema para gestão de clínicas e consultórios
</p>
<p class="m-0 cursor-default">
<a href="https://www.linkedin.com/company/clinyx" target="_blank"
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
LinkedIn
</a>
&bull;
<a href="https://www.facebook.com/clinyx" target="_blank"
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
Facebook
</a>
&bull;
<a href="https://instagram.com/useclinyx" target="_blank"
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
Instagram
</a>
&bull;
<a href="https://youtube.com/@useclinyx" target="_blank"
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
YouTube
</a>
Open-source social media scheduling tool
</p>
@if(isset($unsubscribe_url))

View file

@ -1,5 +1,5 @@
<div class="my-12 sm:my-8 text-center">
<a href="https://trypost.it" target="_blank">
<img src="@{{ asset('/images/emails/trypost/logo-header.png') }}" width="160" alt="Trypost">
<img src="@{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost">
</a>
</div>

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="pt-BR" xmlns:v="urn:schemas-microsoft-com:vml">
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
@ -39,15 +39,15 @@
</head>
<body class="m-0 p-0 w-full [word-break:break-word] [-webkit-font-smoothing:antialiased] {{ page.bodyClass || '' }}">
@if(isset($preheader))
@if(isset($previewText))
<div class="hidden">
@{{ $preheader }}
@{{ $previewText }}
<each loop="item in Array.from(Array(150))">&#8199;&#65279;&#847; </each>
</div>
@endif
<div role="article" aria-roledescription="email" aria-label="@{{ $title }}" lang="pt-BR">
<div role="article" aria-roledescription="email" aria-label="@{{ $title }}" lang="en">
<yield />
</div>
</body>
</html>
</html>

View file

@ -8,30 +8,30 @@
<tr>
<td class="p-12 sm:px-6 text-base text-zinc-800 bg-white rounded shadow-sm">
<p class="leading-6 mt-0">
Olá @{{$user->name}},
Hi @{{ $user->name }},
</p>
<p class="leading-6">
Por favor, confirme seu endereço de e-mail clicando no botão abaixo:
Please confirm your email address by clicking the button below:
</p>
<x-spacer height="24px" />
<div class="text-center">
<x-button href="@{{ $url }}">
Confirmar Endereço de E-mail &rarr;
Verify Email Address &rarr;
</x-button>
</div>
<x-spacer height="24px" />
<p class="leading-6">
Se você não criou esta conta, pode ignorar este e-mail com segurança.
If you did not create an account, you can safely ignore this email.
</p>
<p class="leading-6 mb-0">
Atenciosamente,
Best regards,
<br />
Equipe Clinyx
The TryPost Team
</p>
</td>
</tr>
@ -41,4 +41,4 @@
</tr>
</table>
</div>
</x-main>
</x-main>

View file

@ -8,30 +8,30 @@
<tr>
<td class="p-12 sm:px-6 text-base text-zinc-800 bg-white rounded shadow-sm">
<p class="leading-6 mt-0">
Olá @{{$user->name}},
Hi @{{ $user->name }},
</p>
<p class="leading-6">
Recebemos uma solicitação para redefinir a senha da sua conta. Clique no botão abaixo para criar uma nova senha:
We received a request to reset your password. Click the button below to create a new password:
</p>
<x-spacer height="24px" />
<div class="text-center">
<x-button href="@{{ $url }}">
Redefinir Senha &rarr;
Reset Password &rarr;
</x-button>
</div>
<x-spacer height="24px" />
<p class="leading-6">
Este link expira em 60 minutos. Se você não solicitou a redefinição de senha, pode ignorar este e-mail com segurança.
This link expires in 60 minutes. If you didn't request a password reset, you can safely ignore this email.
</p>
<p class="leading-6 mb-0">
Atenciosamente,
Best regards,
<br />
Equipe Clinyx
The TryPost Team
</p>
</td>
</tr>

View file

@ -1,7 +1,3 @@
---
bodyClass: bg-slate-50
preheader: Convite para o sistema da Clinyx
---
<x-main>
<div class="bg-zinc-50 sm:px-4 font-sans">
<table align="center">
@ -13,23 +9,30 @@
<tr>
<td class="p-12 sm:px-6 text-base text-zinc-700 bg-white rounded shadow-sm">
<h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
Olá 👋
You've been invited!
</h1>
<p class="m-0 leading-6">
Você foi convidado para acessar o sistema da Clinyx.
<br />
<br />
Para aceitar o convite e começar a utilizar a plataforma, clique no botão abaixo.
<strong>@{{ $invite->inviter->name }}</strong> has invited you to collaborate on the <strong>@{{ $invite->workspace->name }}</strong> workspace.
</p>
<p class="m-0 mt-4 leading-6">
You've been invited as <strong>@{{ $invite->role->label() }}</strong>.
</p>
<x-spacer height="24px" />
<div class="flex items-center justify-center">
<x-button href="@{{ $url }}">
Aceitar convite &rarr;
Accept Invite &rarr;
</x-button>
</div>
<x-spacer height="24px" />
<p class="m-0 leading-6 text-zinc-500 text-sm">
This invite expires in 7 days.
</p>
</td>
</tr>
</table>
@ -38,4 +41,4 @@ <h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
</tr>
</table>
</div>
</x-main>
</x-main>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Link, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import { useActiveUrl } from '@/composables/useActiveUrl';
@ -11,38 +12,47 @@ import { edit as editProfile } from '@/routes/profile';
import { show } from '@/routes/two-factor';
import { edit as editPassword } from '@/routes/user-password';
import { settings as workspaceSettings } from '@/routes/workspace';
import { type NavItem } from '@/types';
import { type NavItem, type SharedData } from '@/types';
const navItems: NavItem[] = [
{
title: 'Profile',
href: editProfile(),
},
{
title: 'Password',
href: editPassword(),
},
{
title: 'Two-Factor Auth',
href: show(),
},
{
title: 'Appearance',
href: editAppearance(),
},
{
title: 'Workspace',
href: workspaceSettings(),
},
{
title: 'Members',
href: members(),
},
{
title: 'Billing',
href: billing(),
},
];
const page = usePage<SharedData>();
const navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
title: 'Profile',
href: editProfile(),
},
{
title: 'Password',
href: editPassword(),
},
{
title: 'Two-Factor Auth',
href: show(),
},
{
title: 'Appearance',
href: editAppearance(),
},
{
title: 'Workspace',
href: workspaceSettings(),
},
{
title: 'Members',
href: members(),
},
];
if (!page.props.selfHosted) {
items.push({
title: 'Billing',
href: billing(),
});
}
return items;
});
const { urlIsActive } = useActiveUrl();
</script>

View file

@ -17,6 +17,7 @@ defineProps<{
status?: string;
canResetPassword: boolean;
canRegister: boolean;
email?: string | null;
}>();
</script>
@ -52,6 +53,7 @@ defineProps<{
:tabindex="1"
autocomplete="email"
placeholder="email@example.com"
:default-value="email ?? ''"
/>
<InputError :message="errors.email" />
</div>

View file

@ -10,6 +10,10 @@ import { Spinner } from '@/components/ui/spinner';
import AuthBase from '@/layouts/AuthLayout.vue';
import { login } from '@/routes';
import { store } from '@/routes/register';
defineProps<{
email?: string | null;
}>();
</script>
<template>
@ -51,6 +55,7 @@ import { store } from '@/routes/register';
autocomplete="email"
name="email"
placeholder="email@example.com"
:default-value="email ?? ''"
/>
<InputError :message="errors.email" />
</div>

View file

@ -0,0 +1,138 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { login, register } from '@/routes';
import { ref } from 'vue';
const props = defineProps<{
invite: {
id: string;
token: string;
email: string;
role: {
value: string;
label: string;
};
workspace: {
id: string;
name: string;
};
inviter: {
id: string;
name: string;
email: string;
};
};
isAuthenticated: boolean;
userEmail?: string;
}>();
const accepting = ref(false);
const acceptInvite = () => {
accepting.value = true;
router.post(`/invites/${props.invite.token}/accept`, {}, {
onFinish: () => {
accepting.value = false;
},
});
};
</script>
<template>
<div class="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
<Head title="Accept Invite" />
<div class="w-full max-w-md">
<div class="flex flex-col gap-8">
<div class="flex flex-col items-center gap-4">
<Link href="/" class="flex flex-col items-center gap-2 font-medium">
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-8 w-auto" />
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-8 w-auto" />
</Link>
</div>
<Card>
<CardHeader class="text-center">
<CardTitle class="text-xl">You've been invited!</CardTitle>
<CardDescription>
<span class="font-medium text-foreground">{{ invite.inviter.name }}</span>
has invited you to join the
<span class="font-medium text-foreground">{{ invite.workspace.name }}</span>
workspace.
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="rounded-lg bg-muted p-4 space-y-2">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Workspace</span>
<span class="font-medium">{{ invite.workspace.name }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Your role</span>
<span class="font-medium">{{ invite.role.label }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Invited by</span>
<span class="font-medium">{{ invite.inviter.name }}</span>
</div>
</div>
<!-- Authenticated user -->
<template v-if="isAuthenticated">
<p class="text-sm text-muted-foreground text-center">
You're logged in as <span class="font-medium text-foreground">{{ userEmail }}</span>
</p>
<Button
@click="acceptInvite"
class="w-full"
size="lg"
:disabled="accepting"
>
<Spinner v-if="accepting" class="mr-2" />
Accept Invite
</Button>
</template>
<!-- Not authenticated -->
<template v-else>
<div class="space-y-3">
<p class="text-sm text-muted-foreground text-center">
To accept this invite, please log in or create an account.
</p>
<div class="grid gap-2">
<Button
as-child
class="w-full"
size="lg"
>
<Link :href="login({ query: { email: invite.email } })">
Log in to accept
</Link>
</Button>
<Button
as-child
variant="outline"
class="w-full"
size="lg"
>
<Link :href="register({ query: { email: invite.email } })">
Create an account
</Link>
</Button>
</div>
</div>
</template>
</CardContent>
</Card>
<p class="text-center text-xs text-muted-foreground">
This invite was sent to <span class="font-medium">{{ invite.email }}</span>
</p>
</div>
</div>
</div>
</template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Head, useForm, router } from '@inertiajs/vue3';
import { UserPlus, Users, Mail, Clock, Trash2, Crown, User, Shield } from 'lucide-vue-next';
import { UserPlus, Users, Mail, Trash2, Crown, User, Shield } from 'lucide-vue-next';
import HeadingSmall from '@/components/HeadingSmall.vue';
import { Badge } from '@/components/ui/badge';
@ -31,9 +31,8 @@ interface Member {
interface Invite {
id: string;
email: string;
role: { value: string; label: string };
status: { value: string; label: string; color: string };
expires_at: string;
role: string;
status: string;
inviter: { name: string };
}
@ -86,22 +85,29 @@ function handleRemoveMember(memberId: string) {
}
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
function getStatusLabel(status: string): string {
const labels: Record<string, string> = {
pending: 'Pending',
accepted: 'Accepted',
};
return labels[status] || status;
}
function getStatusColor(color: string): string {
function getStatusColor(status: string): string {
const colors: Record<string, string> = {
yellow: 'bg-yellow-100 text-yellow-800',
green: 'bg-green-100 text-green-800',
gray: 'bg-gray-100 text-gray-800',
red: 'bg-red-100 text-red-800',
pending: 'bg-yellow-100 text-yellow-800',
accepted: 'bg-green-100 text-green-800',
};
return colors[color] || 'bg-gray-100 text-gray-800';
return colors[status] || 'bg-gray-100 text-gray-800';
}
function getRoleLabel(role: string): string {
const labels: Record<string, string> = {
owner: 'Owner',
admin: 'Admin',
member: 'Member',
};
return labels[role] || role;
}
function getRoleIcon(role: string) {
@ -178,7 +184,7 @@ function getRoleIcon(role: string) {
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Clock class="h-5 w-5" />
<Mail class="h-5 w-5" />
Pending Invites
</CardTitle>
<CardDescription>
@ -199,18 +205,15 @@ function getRoleIcon(role: string) {
<p class="font-medium truncate">{{ invite.email }}</p>
<div class="flex items-center gap-2 mt-1">
<Badge variant="outline" class="text-xs">
{{ invite.role.label }}
{{ getRoleLabel(invite.role) }}
</Badge>
<Badge :class="getStatusColor(invite.status.color)" class="text-xs">
{{ invite.status.label }}
<Badge :class="getStatusColor(invite.status)" class="text-xs">
{{ getStatusLabel(invite.status) }}
</Badge>
</div>
<p class="text-xs text-muted-foreground mt-1">
Expires {{ formatDate(invite.expires_at) }}
</p>
</div>
<Button
v-if="invite.status.value === 'pending'"
v-if="invite.status === 'pending'"
variant="ghost"
size="icon"
@click="cancelInvite(invite.id)"

View file

@ -17,14 +17,17 @@ export interface NavItem {
isActive?: boolean;
}
export type AppPageProps<
T extends Record<string, unknown> = Record<string, unknown>,
> = T & {
export interface SharedData {
name: string;
auth: Auth;
sidebarOpen: boolean;
selfHosted: boolean;
[key: string]: unknown;
};
}
export type AppPageProps<
T extends Record<string, unknown> = Record<string, unknown>,
> = T & SharedData;
export interface User {
id: number;

View file

@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
</style>
<![endif]-->
@if(isset($title))
<title>{{ $title }}</title>
@endif
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" media="screen">
<style>
.hover-i-text-decoration-underline:hover {
text-decoration: underline !important
}
@media (max-width: 600px) {
.sm-my-8 {
margin-top: 32px !important;
margin-bottom: 32px !important
}
.sm-px-4 {
padding-left: 16px !important;
padding-right: 16px !important
}
.sm-px-6 {
padding-left: 24px !important;
padding-right: 24px !important
}
}
</style>
</head>
<body style="margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
@if(isset($previewText))
<div style="display: none">
{{ $previewText }}
&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;
</div>
@endif
<div role="article" aria-roledescription="email" aria-label="{{ $title }}" lang="en">
<div class="sm-px-4" style="background-color: #fafafa; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
<table align="center" cellpadding="0" cellspacing="0" role="none">
<tr>
<td style="width: 552px; max-width: 100%">
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
<a href="https://trypost.it" target="_blank">
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
</a>
</div>
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
<tr>
<td class="sm-px-6" style="border-radius: 4px; background-color: #fffffe; padding: 48px; font-size: 16px; color: #27272a; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
<p style="margin-top: 0; line-height: 24px">
Hi {{ $user->name }},
</p>
<p style="line-height: 24px">
Please confirm your email address by clicking the button below:
</p>
<div role="separator" style="line-height: 24px">&zwj;</div>
<div style="text-align: center">
<div>
<a href="{{ $url }}" style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 8px; background-color: #262626; color: #ffffff">
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden>&emsp;</i><![endif]-->
<span style="mso-text-raise: 16px">Verify Email Address &rarr;</span>
<!--[if mso]><i hidden style="mso-font-width: 150%">&emsp;&#8203;</i><![endif]-->
</a>
</div>
</div>
<div role="separator" style="line-height: 24px">&zwj;</div>
<p style="line-height: 24px">
If you did not create an account, you can safely ignore this email.
</p>
<p style="margin-bottom: 0; line-height: 24px">
Best regards,
<br>
The TryPost Team
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 24px; text-align: center; font-size: 12px; color: #52525b">
<p style="margin: 0 0 8px">
Open-source social media scheduling tool
</p>
@if(isset($unsubscribe_url))
<p style="margin: 8px 0 0">
<a href="{{ unsubscribe_url }}" target="_blank" class="hover-i-text-decoration-underline" style="color: #52525b; text-decoration: none">
Unsubscribe
</a>
</p>
@endif
</td>
</tr>
</table>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
</style>
<![endif]-->
@if(isset($title))
<title>{{ $title }}</title>
@endif
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" media="screen">
<style>
.hover-i-text-decoration-underline:hover {
text-decoration: underline !important
}
@media (max-width: 600px) {
.sm-my-8 {
margin-top: 32px !important;
margin-bottom: 32px !important
}
.sm-px-4 {
padding-left: 16px !important;
padding-right: 16px !important
}
.sm-px-6 {
padding-left: 24px !important;
padding-right: 24px !important
}
}
</style>
</head>
<body style="margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
@if(isset($previewText))
<div style="display: none">
{{ $previewText }}
&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;
</div>
@endif
<div role="article" aria-roledescription="email" aria-label="{{ $title }}" lang="en">
<div class="sm-px-4" style="background-color: #fafafa; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
<table align="center" cellpadding="0" cellspacing="0" role="none">
<tr>
<td style="width: 552px; max-width: 100%">
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
<a href="https://trypost.it" target="_blank">
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
</a>
</div>
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
<tr>
<td class="sm-px-6" style="border-radius: 4px; background-color: #fffffe; padding: 48px; font-size: 16px; color: #27272a; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
<p style="margin-top: 0; line-height: 24px">
Hi {{ $user->name }},
</p>
<p style="line-height: 24px">
We received a request to reset your password. Click the button below to create a new password:
</p>
<div role="separator" style="line-height: 24px">&zwj;</div>
<div style="text-align: center">
<div>
<a href="{{ $url }}" style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 8px; background-color: #262626; color: #ffffff">
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden>&emsp;</i><![endif]-->
<span style="mso-text-raise: 16px">Reset Password &rarr;</span>
<!--[if mso]><i hidden style="mso-font-width: 150%">&emsp;&#8203;</i><![endif]-->
</a>
</div>
</div>
<div role="separator" style="line-height: 24px">&zwj;</div>
<p style="line-height: 24px">
This link expires in 60 minutes. If you didn't request a password reset, you can safely ignore this email.
</p>
<p style="margin-bottom: 0; line-height: 24px">
Best regards,
<br>
The TryPost Team
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 24px; text-align: center; font-size: 12px; color: #52525b">
<p style="margin: 0 0 8px">
Open-source social media scheduling tool
</p>
@if(isset($unsubscribe_url))
<p style="margin: 8px 0 0">
<a href="{{ unsubscribe_url }}" target="_blank" class="hover-i-text-decoration-underline" style="color: #52525b; text-decoration: none">
Unsubscribe
</a>
</p>
@endif
</td>
</tr>
</table>
</div>
</div>
</body>
</html>

View file

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

View file

@ -158,8 +158,10 @@
Route::get('billing/portal', [BillingController::class, 'portal'])->name('billing.portal');
});
// Accept invite (accessible with or without auth)
Route::get('invites/{token}/accept', [WorkspaceInviteController::class, 'accept'])
// Invite routes (accessible with or without auth)
Route::get('invites/{token}/accept', [WorkspaceInviteController::class, 'show'])
->name('invites.show');
Route::post('invites/{token}/accept', [WorkspaceInviteController::class, 'accept'])
->name('invites.accept');
require __DIR__.'/settings.php';