Merge branch 'cryptoPM'
This commit is contained in:
36
.env.example
36
.env.example
@@ -63,3 +63,39 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
DEBUGBAR_ENABLED=false
|
||||
|
||||
REMOTE_DB_HOST=
|
||||
REMOTE_DB_PORT=
|
||||
REMOTE_DB_DATABASE=
|
||||
REMOTE_DB_USERNAME=
|
||||
REMOTE_DB_PASSWORD=
|
||||
|
||||
NOTIFY_TG_BOT_TOKEN=
|
||||
NOTIFY_TG_CHAT_ID=
|
||||
|
||||
OXAPAY_MERCHANT_API_KEY=
|
||||
OXAPAY_PAYOUT_API_KEY=
|
||||
|
||||
FORCE_DB_MAIL=false
|
||||
AUTO_FETCH_MAIL=false
|
||||
FETCH_FETCH_FOR_DB=true
|
||||
FETCH_FROM_REMOTE_DB=true
|
||||
MOVE_OR_DELETE=delete
|
||||
#Provide Mailbox Folder Name to Which want to move, else put 'delete' to remove
|
||||
|
||||
STRIPE_KEY=
|
||||
STRIPE_SECRET=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
CASHIER_LOGGER=stack
|
||||
|
||||
#IMAP_HOST=
|
||||
#IMAP_PORT=
|
||||
#IMAP_ENCRYPTION=
|
||||
#IMAP_VALIDATE_CERT=
|
||||
#IMAP_USERNAME=
|
||||
#IMAP_PASSWORD=
|
||||
#IMAP_DEFAULT_ACCOUNT=
|
||||
#IMAP_PROTOCOL=
|
||||
#IMAP_CC_CHECK=
|
||||
|
||||
154
app/Filament/Pages/GenerateActivationKeys.php
Normal file
154
app/Filament/Pages/GenerateActivationKeys.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\ActivationKey;
|
||||
use App\Models\Plan;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Actions\BulkAction;
|
||||
use Filament\Tables\Columns\BooleanColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Illuminate\Support\Collection;
|
||||
use Response;
|
||||
use Str;
|
||||
use Filament\Notifications\Notification;
|
||||
|
||||
class GenerateActivationKeys extends Page implements HasForms, HasTable
|
||||
{
|
||||
use InteractsWithForms, InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-key';
|
||||
protected static string $view = 'filament.pages.generate-activation-keys';
|
||||
protected static ?string $navigationGroup = 'Admin';
|
||||
protected static ?string $title = 'Activation Keys';
|
||||
|
||||
public $plan_id;
|
||||
public $quantity = 1;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill();
|
||||
}
|
||||
|
||||
protected function getFormSchema(): array
|
||||
{
|
||||
return [
|
||||
Select::make('plan_id')
|
||||
->label('Select Plan')
|
||||
->options(Plan::all()->pluck('name', 'id'))
|
||||
->required(),
|
||||
|
||||
TextInput::make('quantity')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->maxValue(100)
|
||||
->default(1)
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
|
||||
public function generate()
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
$plan = Plan::findOrFail($data['plan_id']);
|
||||
|
||||
for ($i = 0; $i < $data['quantity']; $i++) {
|
||||
ActivationKey::create([
|
||||
'price_id' => $plan->pricing_id,
|
||||
'activation_key' => strtoupper('Z'.Str::random(16)),
|
||||
'is_activated' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title("{$data['quantity']} activation key(s) generated.")
|
||||
->success()
|
||||
->send();
|
||||
$this->form->fill(); // Reset form
|
||||
}
|
||||
|
||||
// === Table Setup ===
|
||||
protected function getTableQuery(): \Illuminate\Database\Eloquent\Builder
|
||||
{
|
||||
return ActivationKey::query()->latest();
|
||||
}
|
||||
|
||||
protected function getTableColumns(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('activation_key')
|
||||
->label('Key')
|
||||
->copyable(),
|
||||
|
||||
BooleanColumn::make('is_activated'),
|
||||
|
||||
TextColumn::make('user.email')
|
||||
->label('Activated By'),
|
||||
|
||||
TextColumn::make('billing_interval')
|
||||
->label('Interval')
|
||||
->getStateUsing(function ($record) {
|
||||
$isMonthly = \App\Models\Plan::where('pricing_id', $record->price_id)->value('monthly_billing');
|
||||
return $isMonthly ? 'Monthly' : 'Yearly';
|
||||
}),
|
||||
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->dateTime(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableFilters(): array
|
||||
{
|
||||
return [
|
||||
SelectFilter::make('is_activated')
|
||||
->options([
|
||||
true => 'Activated',
|
||||
false => 'Not Activated',
|
||||
]),
|
||||
SelectFilter::make('price_id')
|
||||
->label('Plan')
|
||||
->options(
|
||||
Plan::pluck('name', 'pricing_id')
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableBulkActions(): array
|
||||
{
|
||||
return [
|
||||
BulkAction::make('Download Keys')
|
||||
->action(fn (Collection $records) => $this->downloadKeys($records))
|
||||
->deselectRecordsAfterCompletion()
|
||||
->requiresConfirmation(),
|
||||
];
|
||||
}
|
||||
|
||||
public function downloadKeys(Collection $records)
|
||||
{
|
||||
$text = $records->pluck('activation_key')->implode("\n");
|
||||
|
||||
$filename = 'activation_keys_' . now()->timestamp . '.txt';
|
||||
// Store the file in the 'public' directory or a subdirectory within 'public'
|
||||
$path = public_path("activation/{$filename}");
|
||||
|
||||
// Make sure the 'activation' folder exists, create it if it doesn't
|
||||
if (!file_exists(public_path('activation'))) {
|
||||
mkdir(public_path('activation'), 0777, true);
|
||||
}
|
||||
|
||||
// Write the contents to the file
|
||||
file_put_contents($path, $text);
|
||||
|
||||
// Return the response that allows users to download the file directly
|
||||
return response()->download($path)->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -45,12 +45,26 @@ class PlanResource extends Resource
|
||||
TextInput::make('description'),
|
||||
TextInput::make('product_id')->required(),
|
||||
TextInput::make('pricing_id')->required(),
|
||||
TextInput::make('shoppy_product_id')->nullable(),
|
||||
TextInput::make('oxapay_link')->nullable(),
|
||||
TextInput::make('price')->numeric()->required(),
|
||||
TextInput::make('mailbox_limit')->numeric()->required(),
|
||||
Select::make('monthly_billing')->options([
|
||||
1 => 'Monthly',
|
||||
0 => 'Yearly',
|
||||
])->default(1)->required(),
|
||||
])->required(),
|
||||
Select::make('accept_stripe')->options([
|
||||
1 => 'Activate',
|
||||
0 => 'Disable',
|
||||
])->required(),
|
||||
Select::make('accept_shoppy')->options([
|
||||
1 => 'Activate',
|
||||
0 => 'Disable',
|
||||
])->required(),
|
||||
Select::make('accept_oxapay')->options([
|
||||
1 => 'Activate',
|
||||
0 => 'Disable',
|
||||
])->required(),
|
||||
KeyValue::make('details')
|
||||
->label('Plan Details (Optional)')
|
||||
->keyPlaceholder('Name')
|
||||
|
||||
244
app/Filament/Resources/TicketResource.php
Normal file
244
app/Filament/Resources/TicketResource.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TicketResource\Pages;
|
||||
use App\Filament\Resources\TicketResource\RelationManagers;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketResponse;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class TicketResource extends Resource
|
||||
{
|
||||
|
||||
protected static ?string $model = Ticket::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-ticket';
|
||||
protected static ?string $navigationGroup = 'Support';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Select::make('user_id')
|
||||
->relationship('user', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
|
||||
Select::make('status')
|
||||
->label('Status')
|
||||
->options([
|
||||
'open' => 'Open',
|
||||
'pending' => 'In Progress',
|
||||
'closed' => 'Closed',
|
||||
])
|
||||
->default('open')
|
||||
->required(),
|
||||
|
||||
TextInput::make('subject')
|
||||
->label('Subject')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextArea::make('message')
|
||||
->label('Message')
|
||||
->required()
|
||||
->rows(7)
|
||||
->maxLength(2000)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('ticket_id')
|
||||
->label('Ticket ID')
|
||||
->sortable(),
|
||||
TextColumn::make('user.name'),
|
||||
TextColumn::make('subject')
|
||||
->limit(50)
|
||||
->label('Subject'),
|
||||
BadgeColumn::make('status')
|
||||
->colors([
|
||||
'success' => fn ($state) => $state === 'open',
|
||||
'warning' => fn ($state) => $state === 'pending',
|
||||
'danger' => fn ($state) => $state === 'closed',
|
||||
])
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->label('Created At')
|
||||
->dateTime('F d, Y • h:i A')->sortable(),
|
||||
TextColumn::make('last_response_at')
|
||||
->label('Last Response')
|
||||
->sortable()
|
||||
->formatStateUsing(fn ($state) => $state?->diffForHumans()),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label('Status')
|
||||
->options([
|
||||
'open' => 'Open',
|
||||
'pending' => 'In Progress',
|
||||
'closed' => 'Closed',
|
||||
])
|
||||
->attribute('status'),
|
||||
Filter::make('created_at')
|
||||
->form([
|
||||
DatePicker::make('created_from')->label('Created From'),
|
||||
DatePicker::make('created_until')->label('Created Until'),
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
return $query
|
||||
->when($data['created_from'], fn ($query, $date) => $query->whereDate('created_at', '>=', $date))
|
||||
->when($data['created_until'], fn ($query, $date) => $query->whereDate('created_at', '<=', $date));
|
||||
}),
|
||||
])
|
||||
// ->actions([
|
||||
// Tables\Actions\EditAction::make(),
|
||||
// ])
|
||||
->actions([
|
||||
Action::make('view')
|
||||
->label('View & Respond')
|
||||
->icon('heroicon-o-eye')
|
||||
->form(function (Ticket $ticket): array {
|
||||
return [
|
||||
TextArea::make('response')
|
||||
->label('Your Response')
|
||||
->required()
|
||||
->rows(7)
|
||||
->placeholder('Type your response here...'),
|
||||
|
||||
Select::make('status')
|
||||
->label('Ticket Status')
|
||||
->options([
|
||||
'open' => 'Open',
|
||||
'pending' => 'In Progress',
|
||||
'closed' => 'Closed',
|
||||
])
|
||||
->default($ticket->status === 'open' ? 'pending' : $ticket->status)
|
||||
->required(),
|
||||
];
|
||||
})
|
||||
->modalContent(function (Ticket $ticket) {
|
||||
$html = '<div class="space-y-3 mb-3 text-sm">';
|
||||
|
||||
// Ticket Subject & Message
|
||||
$html .= '<div class="bg-gray-100 dark:bg-gray-900 border p-2 rounded-md">';
|
||||
$html .= '<h6 class="xs font-semibold text-gray-800 dark:text-gray-100">Subject: ' . e($ticket->subject) . '</h6>';
|
||||
$html .= '<p class="mt-1 text-gray-700 dark:text-gray-300">Message: ' . nl2br(e($ticket->message)) . '</p>';
|
||||
$html .= '</div>';
|
||||
|
||||
// Responses Section
|
||||
$html .= '<h6 class="text-xs font-bold text-gray-700 dark:text-gray-200 mt-2">Previous Responses</h6>';
|
||||
|
||||
foreach ($ticket->responses as $response) {
|
||||
$html .= '<div class="rounded-md border p-2 bg-gray-50 dark:bg-gray-800">';
|
||||
$html .= '<div class="text-xs text-gray-600 dark:text-gray-300">';
|
||||
$html .= '<strong>' . e($response->user->name) . '</strong>';
|
||||
$html .= '<span class="ml-2 text-[11px] text-gray-500">' . e($response->created_at->diffForHumans()) . '</span>';
|
||||
$html .= '</div>';
|
||||
$html .= '<p class="mt-1 text-gray-800 dark:text-gray-100">' . nl2br(e($response->response)) . '</p>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
if ($ticket->responses->isEmpty()) {
|
||||
$html .= '<p class="text-xs text-gray-500">No responses yet.</p>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return new \Illuminate\Support\HtmlString($html);
|
||||
})
|
||||
|
||||
->action(function (array $data, Ticket $ticket) {
|
||||
TicketResponse::create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'user_id' => auth()->id(),
|
||||
'response' => $data['response'],
|
||||
]);
|
||||
|
||||
// Update ticket status and last response time
|
||||
$ticket->update([
|
||||
'status' => $data['status'],
|
||||
'last_response_at' => now(),
|
||||
]);
|
||||
|
||||
// Send success notification
|
||||
Notification::make()
|
||||
->title('Response sent successfully!')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->modalHeading('View & Respond to Ticket')
|
||||
->modalSubmitActionLabel('Send Reply'),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
Tables\Actions\BulkAction::make('notify_users')
|
||||
->label('Send Email Notification')
|
||||
->color('success')
|
||||
->icon('heroicon-o-envelope')
|
||||
->requiresConfirmation()
|
||||
->deselectRecordsAfterCompletion()
|
||||
->action(function (\Illuminate\Support\Collection $records) {
|
||||
foreach ($records as $ticket) {
|
||||
$responses = $ticket->responses()
|
||||
->with('user')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($ticket->user && $ticket->user->email) {
|
||||
\Illuminate\Support\Facades\Mail::to($ticket->user->email)
|
||||
->send(new \App\Mail\TicketResponseNotification($ticket, $responses));
|
||||
}
|
||||
}
|
||||
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title('Email notifications sent successfully!')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\ResponsesRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListTickets::route('/'),
|
||||
'create' => Pages\CreateTicket::route('/create'),
|
||||
'edit' => Pages\EditTicket::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
12
app/Filament/Resources/TicketResource/Pages/CreateTicket.php
Normal file
12
app/Filament/Resources/TicketResource/Pages/CreateTicket.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TicketResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TicketResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTicket extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TicketResource::class;
|
||||
}
|
||||
19
app/Filament/Resources/TicketResource/Pages/EditTicket.php
Normal file
19
app/Filament/Resources/TicketResource/Pages/EditTicket.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TicketResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TicketResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditTicket extends EditRecord
|
||||
{
|
||||
protected static string $resource = TicketResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/TicketResource/Pages/ListTickets.php
Normal file
19
app/Filament/Resources/TicketResource/Pages/ListTickets.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TicketResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TicketResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTickets extends ListRecords
|
||||
{
|
||||
protected static string $resource = TicketResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TicketResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class ResponsesRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'responses';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Select::make('user_id')
|
||||
->relationship('user', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->default(1)
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
Textarea::make('response')
|
||||
->required()
|
||||
->rows(7)
|
||||
->maxLength(2000)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('response')
|
||||
->columns([
|
||||
TextColumn::make('user.name'),
|
||||
TextColumn::make('ip_address'),
|
||||
TextColumn::make('response'),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
216
app/Filament/Resources/UserResource.php
Normal file
216
app/Filament/Resources/UserResource.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\UserResource\Pages;
|
||||
use App\Filament\Resources\UserResource\RelationManagers\LogsRelationManager;
|
||||
use App\Filament\Resources\UserResource\RelationManagers\UsageLogsRelationManager;
|
||||
use App\Models\User;
|
||||
use DB;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
|
||||
class UserResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-users';
|
||||
|
||||
protected static ?string $navigationGroup = 'Admin';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('email')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email_verified_at')
|
||||
->label('Email Verification Status')
|
||||
->disabled()
|
||||
->formatStateUsing(fn ($record) => $record->email_verified_at ?? ''
|
||||
? 'Verified at ' . $record->email_verified_at->toDateTimeString()
|
||||
: 'Not Verified')
|
||||
->helperText('Shows whether the user has verified their email address.'),
|
||||
TextInput::make('stripe_id')
|
||||
->label('Stripe ID')
|
||||
->disabled()
|
||||
->helperText('Automatically managed by Stripe'),
|
||||
|
||||
TextInput::make('pm_type')
|
||||
->label('Payment Method Type')
|
||||
->disabled(),
|
||||
|
||||
TextInput::make('pm_last_four')
|
||||
->label('Card Last 4 Digits')
|
||||
->disabled(),
|
||||
|
||||
DatePicker::make('trial_ends_at')
|
||||
->label('Trial Ends At')
|
||||
->disabled()
|
||||
->displayFormat('Y-m-d H:i:s'),
|
||||
Select::make('level')
|
||||
->label('User Level')
|
||||
->options([
|
||||
0 => 'Normal User',
|
||||
1 => 'Banned',
|
||||
9 => 'Super Admin',
|
||||
])
|
||||
->required(),
|
||||
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')->sortable()->searchable(),
|
||||
TextColumn::make('email')->sortable()->searchable(),
|
||||
IconColumn::make('email_verified_at')
|
||||
->label('Verified')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('danger')
|
||||
->getStateUsing(fn ($record) => !is_null($record->email_verified_at))
|
||||
->sortable(),
|
||||
BadgeColumn::make('level')
|
||||
->label('User Level')
|
||||
->getStateUsing(function ($record) {
|
||||
return match ($record->level) {
|
||||
0 => 'Normal User',
|
||||
1 => 'Banned',
|
||||
9 => 'Super Admin',
|
||||
default => 'Unknown', // In case some invalid level exists
|
||||
};
|
||||
})
|
||||
->colors([
|
||||
'success' => fn ($state) => $state === 'Normal User',
|
||||
'danger' => fn ($state) => $state === 'Banned',
|
||||
'warning' => fn ($state) => $state === 'Super Admin',
|
||||
])
|
||||
->sortable(),
|
||||
TextColumn::make('stripe_id')->label('Stripe ID')->copyable(),
|
||||
TextColumn::make('pm_last_four')->label('Card Last 4'),
|
||||
TextColumn::make('trial_ends_at')->label('Trial Ends')->dateTime()->sortable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('subscription_status')
|
||||
->label('Subscription Status')
|
||||
->options([
|
||||
'subscribed' => 'Has Active Subscription',
|
||||
'not_subscribed' => 'No Active Subscription',
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
if ($data['value'] === 'subscribed') {
|
||||
$query->whereHas('subscriptions', function ($query) {
|
||||
$query->where('stripe_status', 'active')
|
||||
->orWhere('stripe_status', 'trialing');
|
||||
});
|
||||
} elseif ($data['value'] === 'not_subscribed') {
|
||||
$query->whereDoesntHave('subscriptions', function ($query) {
|
||||
$query->where('stripe_status', 'active')
|
||||
->orWhere('stripe_status', 'trialing');
|
||||
});
|
||||
}
|
||||
}),
|
||||
SelectFilter::make('email_verified')
|
||||
->label('Email Verification')
|
||||
->options([
|
||||
'verified' => 'Verified',
|
||||
'not_verified' => 'Not Verified',
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
if ($data['value'] === 'verified') {
|
||||
$query->whereNotNull('email_verified_at');
|
||||
} elseif ($data['value'] === 'not_verified') {
|
||||
$query->whereNull('email_verified_at');
|
||||
}
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
Tables\Actions\BulkAction::make('updateLevel')
|
||||
->label('Update User Level')
|
||||
->action(function (Collection $records, array $data) {
|
||||
|
||||
$newLevel = $data['new_level'];
|
||||
if ($newLevel === 9) {
|
||||
throw new \Exception('User level cannot be 9 or higher.');
|
||||
}
|
||||
|
||||
DB::table('users')
|
||||
->whereIn('id', $records->pluck('id'))
|
||||
->update(['level' => $newLevel]);
|
||||
|
||||
|
||||
Notification::make()
|
||||
->title('User Level Updated')
|
||||
->body('The selected users\' levels have been updated successfully.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->icon('heroicon-o-pencil')
|
||||
->color('primary')
|
||||
->modalHeading('Select User Level')
|
||||
->modalSubheading('Please choose the user level to apply to the selected users.')
|
||||
->form([
|
||||
Select::make('new_level')
|
||||
->label('Select User Level')
|
||||
->options([
|
||||
0 => 'Unban (Normal User)',
|
||||
1 => 'Ban',
|
||||
])
|
||||
->required(),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
LogsRelationManager::class,
|
||||
UsageLogsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListUsers::route('/'),
|
||||
'create' => Pages\CreateUser::route('/create'),
|
||||
'edit' => Pages\EditUser::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
12
app/Filament/Resources/UserResource/Pages/CreateUser.php
Normal file
12
app/Filament/Resources/UserResource/Pages/CreateUser.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
}
|
||||
88
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
88
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
|
||||
class EditUser extends EditRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
Action::make('download_report')
|
||||
->label('Download User Report')
|
||||
->icon('heroicon-o-user')
|
||||
->action(function (User $record) {
|
||||
$userData = [
|
||||
'Name' => $record->name,
|
||||
'Email' => $record->email,
|
||||
'Stripe ID' => $record->stripe_id ?? 'N/A',
|
||||
'Payment Method Type' => $record->pm_type ?? 'N/A',
|
||||
'Card Last 4' => $record->pm_last_four ?? 'N/A',
|
||||
'Trial Ends At' => $record->trial_ends_at ? $record->trial_ends_at->toDateTimeString() : 'N/A',
|
||||
'User Level' => match ($record->level) {
|
||||
0 => 'Normal User',
|
||||
1 => 'Banned',
|
||||
9 => 'Super Admin',
|
||||
default => 'Unknown',
|
||||
},
|
||||
'Email Verified At' => $record->email_verified_at ? $record->email_verified_at->toDateTimeString() : 'Not Verified',
|
||||
];
|
||||
|
||||
$csv = fopen('php://temp', 'r+');
|
||||
// User Details Header
|
||||
fputcsv($csv, ['User Details']);
|
||||
fputcsv($csv, array_keys($userData));
|
||||
fputcsv($csv, array_values($userData));
|
||||
fputcsv($csv, []);
|
||||
|
||||
// Usage Logs Header
|
||||
fputcsv($csv, ['Usage Logs']);
|
||||
fputcsv($csv, ['IP Address', 'Emails Created Count', 'Emails Received Count', 'Emails Created History', 'Emails Received History', 'Created At']);
|
||||
foreach ($record->usageLogs as $log) {
|
||||
fputcsv($csv, [
|
||||
$log->ip_address,
|
||||
$log->emails_created_count,
|
||||
$log->emails_received_count,
|
||||
is_array($log->emails_created_history) ? implode('; ', $log->emails_created_history) : 'None',
|
||||
is_array($log->emails_received_history) ? implode('; ', $log->emails_received_history) : 'None',
|
||||
$log->created_at->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
fputcsv($csv, []);
|
||||
|
||||
// General Logs Header
|
||||
fputcsv($csv, ['General Logs']);
|
||||
fputcsv($csv, ['IP Address', 'Email', 'Created At']);
|
||||
foreach ($record->logs as $log) {
|
||||
fputcsv($csv, [
|
||||
$log->ip,
|
||||
$log->email,
|
||||
$log->created_at->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
|
||||
rewind($csv);
|
||||
$csvContent = stream_get_contents($csv);
|
||||
fclose($csv);
|
||||
|
||||
return Response::streamDownload(
|
||||
function () use ($csvContent) {
|
||||
echo $csvContent;
|
||||
},
|
||||
"user_{$record->id}_report_" . now()->format('Ymd_His') . '.csv',
|
||||
['Content-Type' => 'text/csv']
|
||||
);
|
||||
})
|
||||
->color('primary'),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
19
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListUsers extends ListRecords
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class LogsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'logs';
|
||||
protected static ?string $title = 'General Logs';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('ip')
|
||||
->label('IP Address')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('email')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Logged At')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
//Tables\Actions\CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
//Tables\Actions\EditAction::make(),
|
||||
//Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
//Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class UsageLogsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'usageLogs';
|
||||
|
||||
protected static ?string $title = 'Email Usage Logs';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('ip_address')
|
||||
->label('IP Address')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('emails_created_count')
|
||||
->label('Emails Created')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('emails_received_count')
|
||||
->label('Emails Received')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('Last Activity At')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
//Tables\Actions\CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
//Tables\Actions\EditAction::make(),
|
||||
//Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
//Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@ namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Log;
|
||||
use App\Models\Meta;
|
||||
use App\Models\PremiumEmail;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use DB;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
@@ -14,34 +17,138 @@ class StatsOverview extends BaseWidget
|
||||
protected function getStats(): array
|
||||
{
|
||||
return [
|
||||
Stat::make('Total Users', $this->getUser()),
|
||||
Stat::make('Paid Users', $this->getUserPaid()),
|
||||
Stat::make('Logs Count', $this->getLogsCount()),
|
||||
Stat::make('Total Mailbox', $this->getTotalMailbox()),
|
||||
Stat::make('Emails Received', $this->getTotalEmailsReceived()),
|
||||
Stat::make('Total Users', $this->getUser())
|
||||
->description($this->getComparisonDescription($this->getUser(), $this->getUser('yesterday')))
|
||||
->descriptionIcon($this->getComparisonIcon($this->getUser(), $this->getUser('yesterday')))
|
||||
->color($this->getComparisonColor($this->getUser(), $this->getUser('yesterday'))),
|
||||
Stat::make('Customers', $this->getCustomerCount())
|
||||
->description($this->getComparisonDescription($this->getCustomerCount(), $this->getCustomerCount('yesterday')))
|
||||
->descriptionIcon($this->getComparisonIcon($this->getCustomerCount(), $this->getCustomerCount('yesterday')))
|
||||
->color($this->getComparisonColor($this->getCustomerCount(), $this->getCustomerCount('yesterday'))),
|
||||
Stat::make('Paid Users', $this->getUserPaid())
|
||||
->description($this->getComparisonDescription($this->getUserPaid(), $this->getUserPaid('yesterday')))
|
||||
->descriptionIcon($this->getComparisonIcon($this->getUserPaid(), $this->getUserPaid('yesterday')))
|
||||
->color($this->getComparisonColor($this->getUserPaid(), $this->getUserPaid('yesterday'))),
|
||||
Stat::make('Logs Count', $this->getLogsCount())
|
||||
->description($this->getComparisonDescription($this->getLogsCount(), $this->getLogsCount('yesterday')))
|
||||
->descriptionIcon($this->getComparisonIcon($this->getLogsCount(), $this->getLogsCount('yesterday')))
|
||||
->color($this->getComparisonColor($this->getLogsCount(), $this->getLogsCount('yesterday'))),
|
||||
Stat::make('Total Mailbox', $this->getTotalMailbox())
|
||||
->description($this->getComparisonDescription($this->getTotalMailbox(), $this->getTotalMailbox('yesterday')))
|
||||
->descriptionIcon($this->getComparisonIcon($this->getTotalMailbox(), $this->getTotalMailbox('yesterday')))
|
||||
->color($this->getComparisonColor($this->getTotalMailbox(), $this->getTotalMailbox('yesterday'))),
|
||||
Stat::make('Emails Received', $this->getTotalEmailsReceived())
|
||||
->description($this->getComparisonDescription($this->getTotalEmailsReceived(), $this->getTotalEmailsReceived('yesterday')))
|
||||
->descriptionIcon($this->getComparisonIcon($this->getTotalEmailsReceived(), $this->getTotalEmailsReceived('yesterday')))
|
||||
->color($this->getComparisonColor($this->getTotalEmailsReceived(), $this->getTotalEmailsReceived('yesterday'))),
|
||||
Stat::make('Emails Stored', $this->getStoreEmailsCount())
|
||||
->description($this->getComparisonDescription($this->getStoreEmailsCount(), $this->getStoreEmailsCount('yesterday')))
|
||||
->descriptionIcon($this->getComparisonIcon($this->getStoreEmailsCount(), $this->getStoreEmailsCount('yesterday')))
|
||||
->color($this->getComparisonColor($this->getStoreEmailsCount(), $this->getStoreEmailsCount('yesterday'))),
|
||||
Stat::make('Open Tickets', $this->getOpenTicketsCount())
|
||||
->description($this->getComparisonDescription($this->getOpenTicketsCount(), $this->getOpenTicketsCount('yesterday')))
|
||||
->descriptionIcon($this->getComparisonIcon($this->getOpenTicketsCount(), $this->getOpenTicketsCount('yesterday')))
|
||||
->color($this->getComparisonColor($this->getOpenTicketsCount(), $this->getOpenTicketsCount('yesterday'))),
|
||||
Stat::make('Closed Tickets', $this->getClosedTicketsCount())
|
||||
->description($this->getComparisonDescription($this->getClosedTicketsCount(), $this->getClosedTicketsCount('yesterday')))
|
||||
->descriptionIcon($this->getComparisonIcon($this->getClosedTicketsCount(), $this->getClosedTicketsCount('yesterday')))
|
||||
->color($this->getComparisonColor($this->getClosedTicketsCount(), $this->getClosedTicketsCount('yesterday'))),
|
||||
];
|
||||
}
|
||||
private function getUser(): int
|
||||
|
||||
private function getComparisonDescription(int $today, int $yesterday): string
|
||||
{
|
||||
return User::all()->count();
|
||||
if ($today == $yesterday) {
|
||||
return 'No change';
|
||||
}
|
||||
|
||||
$difference = $today - $yesterday;
|
||||
$percentage = $yesterday > 0 ? ($difference / $yesterday) * 100 : ($today > 0 ? 100 : 0);
|
||||
|
||||
return sprintf(
|
||||
'Today: %d, Yesterday: %d (%s%.1f%%)',
|
||||
$today,
|
||||
$yesterday,
|
||||
$difference >= 0 ? '+' : '-',
|
||||
abs($percentage)
|
||||
);
|
||||
}
|
||||
private function getUserPaid(): int
|
||||
|
||||
private function getComparisonIcon(int $today, int $yesterday): ?string
|
||||
{
|
||||
if ($today == $yesterday) {
|
||||
return null;
|
||||
}
|
||||
return $today > $yesterday ? 'heroicon-o-arrow-up' : 'heroicon-o-arrow-down';
|
||||
}
|
||||
|
||||
private function getComparisonColor(int $today, int $yesterday): string
|
||||
{
|
||||
if ($today == $yesterday) {
|
||||
return 'gray';
|
||||
}
|
||||
return $today > $yesterday ? 'success' : 'danger';
|
||||
}
|
||||
|
||||
private function getUser(string $period = 'today'): int
|
||||
{
|
||||
if ($period === 'yesterday') {
|
||||
return User::where('created_at', '<', Carbon::today('UTC')->startOfDay())->count();
|
||||
}
|
||||
return User::count();
|
||||
}
|
||||
|
||||
private function getUserPaid(string $period = 'today'): int
|
||||
{
|
||||
return DB::table('subscriptions')
|
||||
->where(['stripe_status' => 'active'])
|
||||
->where('stripe_status', 'active')
|
||||
->distinct('user_id')
|
||||
->count('user_id');
|
||||
}
|
||||
private function getLogsCount(): int
|
||||
|
||||
private function getLogsCount(string $period = 'today'): int
|
||||
{
|
||||
return Log::all()->count();
|
||||
if ($period === 'yesterday') {
|
||||
return Log::where('created_at', '<', Carbon::today('UTC')->startOfDay())->count();
|
||||
}
|
||||
return Log::count();
|
||||
}
|
||||
private function getTotalMailbox(): int
|
||||
|
||||
private function getTotalMailbox(string $period = 'today'): int
|
||||
{
|
||||
return Meta::select('value')->where(['key' => 'email_ids_created'])->first()->value;
|
||||
return Meta::select('value')->where('key', 'email_ids_created')->first()->value ?? 0;
|
||||
}
|
||||
private function getTotalEmailsReceived(): int
|
||||
|
||||
private function getTotalEmailsReceived(string $period = 'today'): int
|
||||
{
|
||||
return Meta::select('value')->where(['key' => 'messages_received'])->first()->value;
|
||||
return Meta::select('value')->where('key', 'messages_received')->first()->value ?? 0;
|
||||
}
|
||||
|
||||
private function getCustomerCount(string $period = 'today'): int
|
||||
{
|
||||
if ($period === 'yesterday') {
|
||||
return User::whereNotNull('stripe_id')
|
||||
->where('created_at', '<', Carbon::today('UTC')->startOfDay())
|
||||
->count();
|
||||
}
|
||||
return User::whereNotNull('stripe_id')->count();
|
||||
}
|
||||
|
||||
private function getStoreEmailsCount(string $period = 'today'): int
|
||||
{
|
||||
if ($period === 'yesterday') {
|
||||
return PremiumEmail::where('created_at', '<', Carbon::today('UTC')->startOfDay())->count();
|
||||
}
|
||||
return PremiumEmail::count();
|
||||
}
|
||||
|
||||
private function getOpenTicketsCount(string $period = 'today'): int
|
||||
{
|
||||
return Ticket::whereIn('status', ['open', 'pending'])->count();
|
||||
}
|
||||
|
||||
private function getClosedTicketsCount(string $period = 'today'): int
|
||||
{
|
||||
return Ticket::whereIn('status', ['closed'])->count();
|
||||
}
|
||||
}
|
||||
|
||||
111
app/Http/Controllers/WebhookController.php
Normal file
111
app/Http/Controllers/WebhookController.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\NotifyMe;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
use NotifyMe;
|
||||
public function oxapay(Request $request)
|
||||
{
|
||||
// Get the request data
|
||||
$postData = $request->getContent();
|
||||
$data = json_decode($postData, true);
|
||||
|
||||
// Validate request data
|
||||
if (!$data || !isset($data['type']) || !in_array($data['type'], ['invoice', 'payment_link', 'payout'])) {
|
||||
\Log::warning('Invalid Oxapay webhook data', ['data' => $data]);
|
||||
return response('Invalid data.type', 400);
|
||||
}
|
||||
|
||||
// Determine API secret key based on type
|
||||
$apiSecretKey = $data['type'] === 'invoice'
|
||||
? config('services.oxapay.merchant_api_key')
|
||||
: config('services.oxapay.payout_api_key');
|
||||
|
||||
// Validate HMAC signature
|
||||
$hmacHeader = $request->header('HMAC');
|
||||
$calculatedHmac = hash_hmac('sha512', $postData, $apiSecretKey);
|
||||
|
||||
if (hash_equals($calculatedHmac, $hmacHeader)) {
|
||||
// HMAC signature is valid
|
||||
try {
|
||||
if ($data['type'] === 'invoice' || $data['type'] === 'payment_link') {
|
||||
// Process invoice payment data
|
||||
$email = $data['email'] ?? 'Unknown';
|
||||
$amount = $data['amount'] ?? 'Unknown';
|
||||
$currency = $data['currency'] ?? 'Unknown';
|
||||
$trackId = $data['track_id'] ?? 'Unknown';
|
||||
$orderId = $data['order_id'] ?? 'N/A';
|
||||
$date = isset($data['date']) ? Carbon::createFromTimestamp($data['date'])->toDateTimeString() : now()->toDateTimeString();
|
||||
|
||||
\Log::info('Received Oxapay invoice payment callback', [
|
||||
'track_id' => $trackId,
|
||||
'email' => $email,
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'order_id' => $orderId,
|
||||
'date' => $date,
|
||||
]);
|
||||
|
||||
$message = "✅ Oxapay Invoice Payment Success\n" .
|
||||
"Track ID: {$trackId}\n" .
|
||||
"Email: {$email}\n" .
|
||||
"Amount: {$amount} {$currency}\n" .
|
||||
"Order ID: {$orderId}\n" .
|
||||
"Time: {$date}";
|
||||
self::sendTelegramNotification($message);
|
||||
} elseif ($data['type'] === 'payout') {
|
||||
// Process payout data
|
||||
$trackId = $data['track_id'] ?? 'Unknown';
|
||||
$amount = $data['amount'] ?? 'Unknown';
|
||||
$currency = $data['currency'] ?? 'Unknown';
|
||||
$network = $data['network'] ?? 'Unknown';
|
||||
$address = $data['address'] ?? 'Unknown';
|
||||
$txHash = $data['tx_hash'] ?? 'Unknown';
|
||||
$description = $data['description'] ?? 'N/A';
|
||||
$date = isset($data['date']) ? Carbon::createFromTimestamp($data['date'])->toDateTimeString() : now()->toDateTimeString();
|
||||
|
||||
\Log::info('Received Oxapay payout callback', [
|
||||
'track_id' => $trackId,
|
||||
'status' => $data['status'] ?? 'Unknown',
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'network' => $network,
|
||||
'address' => $address,
|
||||
'tx_hash' => $txHash,
|
||||
'description' => $description,
|
||||
'date' => $date,
|
||||
]);
|
||||
|
||||
$message = "📤 Oxapay Payout Confirmed\n" .
|
||||
"Track ID: {$trackId}\n" .
|
||||
"Amount: {$amount} {$currency}\n" .
|
||||
"Network: {$network}\n" .
|
||||
"Address: {$address}\n" .
|
||||
"Transaction Hash: {$txHash}\n" .
|
||||
"Description: {$description}\n" .
|
||||
"Date: {$date}";
|
||||
self::sendTelegramNotification($message);
|
||||
}
|
||||
|
||||
return response('OK', 200);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Oxapay webhook processing error', ['error' => $e->getMessage(), 'data' => $data]);
|
||||
self::sendTelegramNotification("
|
||||
Failed to process Oxapay webhook\n
|
||||
Type: {$data['type']}\n
|
||||
Email/Track ID: " . ($data['type'] === 'invoice' ? ($data['email'] ?? 'Unknown') : ($data['track_id'] ?? 'Unknown')) . "\n
|
||||
Error: {$e->getMessage()}
|
||||
");
|
||||
return response('Processing error', 400);
|
||||
}
|
||||
} else {
|
||||
\Log::warning('Invalid Oxapay HMAC signature', ['hmac_header' => $hmacHeader, 'calculated_hmac' => $calculatedHmac]);
|
||||
return response('Invalid HMAC signature', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/Http/Middleware/CheckUserBanned.php
Normal file
25
app/Http/Middleware/CheckUserBanned.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Auth;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CheckUserBanned
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::check() && Auth::user()->level === 1) {
|
||||
// Return the banned page instead of proceeding with the request
|
||||
return response()->view('banned');
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\NotifyMe;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
@@ -11,6 +12,7 @@ use Livewire\Livewire;
|
||||
|
||||
class StripeEventListener
|
||||
{
|
||||
use NotifyMe;
|
||||
/**
|
||||
* Create the event listener.
|
||||
*/
|
||||
@@ -27,11 +29,26 @@ class StripeEventListener
|
||||
if ($event->payload['type'] === 'invoice.payment_succeeded') {
|
||||
session()->flash('alert', ['type' => 'success', 'message' => 'Payment completed successfully.']);
|
||||
Log::info('Payment succeeded');
|
||||
$amount = $event->payload['data']['object']['amount_paid'] / 100 ?? 'Unknown'; // Convert cents to dollars
|
||||
$email = $event->payload['data']['object']['customer_email'] ?? 'Unknown';
|
||||
|
||||
$message = "✅ Payment Success\n" .
|
||||
"Email: {$email}\n" .
|
||||
"Amount: $" . number_format($amount, 2) . "\n" .
|
||||
"Time: " . now()->toDateTimeString();
|
||||
self::sendTelegramNotification($message);
|
||||
}
|
||||
|
||||
if ($event->payload['type'] === 'customer.subscription.deleted') {
|
||||
session()->flash('alert', ['type' => 'error', 'message' => 'Subscription canceled.']);
|
||||
Log::info('Subscription canceled');
|
||||
|
||||
$email = $event->payload['data']['object']['customer_email'] ?? 'Unknown';
|
||||
|
||||
$message = "❌ Subscription Canceled\n" .
|
||||
"Email: {$email}\n" .
|
||||
"Time: " . now()->toDateTimeString();
|
||||
self::sendTelegramNotification($message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class Dashboard extends Component
|
||||
public $usageLog;
|
||||
public $subscription;
|
||||
public $plans;
|
||||
public $showStripeBilling = false;
|
||||
|
||||
public function paymentStatus(Request $request)
|
||||
{
|
||||
@@ -35,7 +36,10 @@ class Dashboard extends Component
|
||||
$user = auth()->user();
|
||||
$userId = $user->id;
|
||||
if ($user->subscribed()) {
|
||||
$subscription = $user->subscriptions()->where(['stripe_status' => 'active'])->orderByDesc('updated_at')->first();
|
||||
$subscription = $user->subscriptions()
|
||||
//->where(['stripe_status' => 'active'])
|
||||
->orderByDesc('updated_at')
|
||||
->first();
|
||||
if ($subscription !== null) {
|
||||
$subscriptionId = $subscription->stripe_id;
|
||||
$cacheKey = "stripe_check_executed_user_{$userId}_{$subscriptionId}";
|
||||
@@ -74,7 +78,7 @@ class Dashboard extends Component
|
||||
]);
|
||||
}
|
||||
}
|
||||
Cache::put($cacheKey, true, now()->addHour());
|
||||
Cache::put($cacheKey, true, now()->addMinute());
|
||||
} catch (Exception $exception) {
|
||||
\Log::error($exception->getMessage());
|
||||
}
|
||||
@@ -176,11 +180,12 @@ class Dashboard extends Component
|
||||
$userPriceID = $result['items'][0]['stripe_price'];
|
||||
$subscriptionEnd = $result['ends_at'];
|
||||
|
||||
$planName = null; // Default value if not found
|
||||
$planName = null;
|
||||
|
||||
foreach (config('app.plans') as $plan) {
|
||||
if ($plan['pricing_id'] === $userPriceID) {
|
||||
$planName = $plan['name'];
|
||||
$this->showStripeBilling = $plan['accept_stripe'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use App\Models\ActivationKey;
|
||||
use App\Models\Plan;
|
||||
use Livewire\Component;
|
||||
|
||||
class Pricing extends Component
|
||||
{
|
||||
public $plans;
|
||||
public $activation_key;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
@@ -18,6 +21,70 @@ class Pricing extends Component
|
||||
$this->redirect(route('checkout', $pricing_id));
|
||||
}
|
||||
|
||||
public function activateKey(): void
|
||||
{
|
||||
$this->validate([
|
||||
'activation_key' => 'required|alpha_num|max:30',
|
||||
], [
|
||||
'activation_key.required' => 'You must enter an activation key.',
|
||||
'activation_key.alpha_num' => 'The activation key may only contain letters and numbers (no special characters).',
|
||||
'activation_key.max' => 'The activation key must not exceed 30 characters.',
|
||||
]);
|
||||
|
||||
$trimmedKey = trim($this->activation_key);
|
||||
$activation = ActivationKey::where('activation_key', $trimmedKey)
|
||||
->where('is_activated', false)
|
||||
->first();
|
||||
|
||||
if ($activation) {
|
||||
if ($activation->price_id !== null) {
|
||||
$result = $this->addSubscription($activation->price_id);
|
||||
}
|
||||
if ($result === true) {
|
||||
$activation->is_activated = true;
|
||||
$activation->user_id = auth()->id();
|
||||
$activation->save();
|
||||
session()->flash('success', 'Activation key is valid and has been activated. Refresh page to see changes.');
|
||||
$this->reset('activation_key');
|
||||
} else {
|
||||
session()->flash('error', 'Something went wrong. Kindly drop a mail at contact@zemail.me to activate your subscription manually.');
|
||||
}
|
||||
} else {
|
||||
session()->flash('error', 'Invalid or already activated key.');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function addSubscription($price_id): bool
|
||||
{
|
||||
try {
|
||||
$plan = Plan::where('pricing_id', $price_id)->firstOrFail();
|
||||
$user = auth()->user();
|
||||
$user->createOrGetStripeCustomer();
|
||||
$user->updateStripeCustomer([
|
||||
'address' => [
|
||||
'postal_code' => '10001',
|
||||
'country' => 'US',
|
||||
],
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
]);
|
||||
$user->creditBalance($plan->price * 100, 'Premium Top-up for plan: ' . $plan->name);
|
||||
$balance = $user->balance();
|
||||
$user->newSubscription('default', $plan->pricing_id)->create();
|
||||
|
||||
if ($plan->monthly_billing == 1) {
|
||||
$ends_at = now()->addMonth();
|
||||
} else {
|
||||
$ends_at = now()->addYear();
|
||||
}
|
||||
$user->subscription('default')->cancelAt($ends_at);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.pricing');
|
||||
|
||||
149
app/Livewire/Dashboard/Support.php
Normal file
149
app/Livewire/Dashboard/Support.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketResponse;
|
||||
use Livewire\Component;
|
||||
use Request;
|
||||
use Str;
|
||||
|
||||
class Support extends Component
|
||||
{
|
||||
public $tickets = [];
|
||||
public $subject;
|
||||
public $message;
|
||||
public $response;
|
||||
public $list = false;
|
||||
public $open = 0;
|
||||
public $closed = 0;
|
||||
|
||||
public function store()
|
||||
{
|
||||
$this->validate([
|
||||
'subject' => 'required|string|max:255',
|
||||
'message' => 'required|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$ticket = Ticket::create([
|
||||
'user_id' => auth()->id(),
|
||||
'ticket_id' => strtoupper(Str::random(6)),
|
||||
'subject' => $this->subject,
|
||||
'message' => $this->message,
|
||||
'ip_address' => $this->getClientIp(),
|
||||
'last_response_at' => now(),
|
||||
]);
|
||||
$this->dispatch('showAlert', ['type' => 'success', 'message' => 'Your ticket has been created successfully!']);
|
||||
$this->dispatch('closeModal');
|
||||
$this->reset(['subject','message']);
|
||||
$this->tickets = Ticket::with('responses')
|
||||
->where('user_id', auth()->id())
|
||||
->get();
|
||||
} catch (\Exception $exception) {
|
||||
$this->dispatch('showAlert', ['type' => 'error', 'message' => 'Something went wrong!']);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function reply($ticket_id)
|
||||
{
|
||||
$this->validate([
|
||||
'response' => 'required|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
|
||||
if (!is_numeric($ticket_id)) {
|
||||
$this->dispatch('showAlert', ['type' => 'error', 'message' => 'Invalid ticket ID.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ticket = Ticket::find($ticket_id);
|
||||
if (!$ticket) {
|
||||
$this->dispatch('showAlert', ['type' => 'error', 'message' => 'Ticket not found.']);
|
||||
return;
|
||||
}
|
||||
|
||||
TicketResponse::create([
|
||||
'ticket_id' => $ticket_id,
|
||||
'user_id' => auth()->id(),
|
||||
'response' => $this->response,
|
||||
'ip_address' => $this->getClientIp(),
|
||||
]);
|
||||
$ticket->last_response_at = now();
|
||||
$ticket->save();
|
||||
|
||||
$this->dispatch('showAlert', ['type' => 'success', 'message' => 'Reply sent successfully!']);
|
||||
|
||||
$this->reset(['response']);
|
||||
$this->tickets = Ticket::with('responses')
|
||||
->where('user_id', auth()->id())
|
||||
->get();
|
||||
|
||||
} catch (\Exception $exception) {
|
||||
session()->flash('error', 'Something went wrong!');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function close($ticket_id)
|
||||
{
|
||||
if (!is_numeric($ticket_id)) {
|
||||
$this->dispatch('showAlert', ['type' => 'error', 'message' => 'Invalid ticket ID.']);
|
||||
return;
|
||||
}
|
||||
$ticket = Ticket::find($ticket_id);
|
||||
if (!$ticket) {
|
||||
$this->dispatch('showAlert', ['type' => 'error', 'message' => 'Ticket not found.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ticket->status = 'closed';
|
||||
$ticket->save();
|
||||
$this->tickets = Ticket::with('responses')
|
||||
->where('user_id', auth()->id())
|
||||
->get();
|
||||
$this->updateTicketCounts();
|
||||
$this->dispatch('showAlert', ['type' => 'error', 'message' => 'This ticket has been closed!']);
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->tickets = Ticket::with('responses')
|
||||
->where('user_id', auth()->id())
|
||||
->get();
|
||||
$this->updateTicketCounts();
|
||||
}
|
||||
|
||||
public function updateTicketCounts()
|
||||
{
|
||||
$this->open = $this->tickets->filter(function ($ticket) {
|
||||
return in_array($ticket->status, ['open', 'pending']);
|
||||
})->count();
|
||||
|
||||
$this->closed = $this->tickets->filter(function ($ticket) {
|
||||
return $ticket->status === 'closed';
|
||||
})->count();
|
||||
}
|
||||
|
||||
protected function getClientIp()
|
||||
{
|
||||
// Cloudflare or other proxies may send the original client IP in X-Forwarded-For header
|
||||
$ip = Request::header('X-Forwarded-For');
|
||||
|
||||
// X-Forwarded-For can contain a comma-separated list of IPs, so we need to get the first one
|
||||
if ($ip) {
|
||||
return explode(',', $ip)[0]; // Get the first IP in the list
|
||||
}
|
||||
|
||||
// Fallback to the real IP if no X-Forwarded-For header
|
||||
return Request::ip();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.support')->layout('components.layouts.dashboard');
|
||||
}
|
||||
}
|
||||
59
app/Mail/TicketResponseNotification.php
Normal file
59
app/Mail/TicketResponseNotification.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketResponse;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TicketResponseNotification extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public Ticket $ticket;
|
||||
public Collection $responses;
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(Ticket $ticket, Collection $responses)
|
||||
{
|
||||
$this->ticket = $ticket;
|
||||
$this->responses = $responses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Support Ticket Response: #'. $this->ticket->ticket_id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.ticket.response',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
46
app/Models/ActivationKey.php
Normal file
46
app/Models/ActivationKey.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ActivationKey extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'activation_key',
|
||||
'price_id',
|
||||
'is_activated',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_activated' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Relationship: the user who redeemed the activation key (optional).
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter unactivated keys
|
||||
*/
|
||||
public function scopeUnactivated($query)
|
||||
{
|
||||
return $query->where('is_activated', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter activated keys
|
||||
*/
|
||||
public function scopeActivated($query)
|
||||
{
|
||||
return $query->where('is_activated', true);
|
||||
}
|
||||
}
|
||||
@@ -272,7 +272,11 @@ class Email extends Model
|
||||
|
||||
public static function parseEmail($email, $deleted = []): array
|
||||
{
|
||||
$messages = self::fetchEmailFromDB($email);
|
||||
if (config('app.fetch_from_remote_db')) {
|
||||
$messages = RemoteEmail::fetchEmailFromDB($email);
|
||||
} else {
|
||||
$messages = self::fetchEmailFromDB($email);
|
||||
}
|
||||
$limit = json_decode(config('app.settings.configuration_settings'))->fetch_messages_limit ?? 15;
|
||||
$count = 1;
|
||||
$response = [
|
||||
@@ -282,9 +286,18 @@ class Email extends Model
|
||||
|
||||
foreach ($messages as $message) {
|
||||
|
||||
// fix for null attachments
|
||||
if ($message['attachments'] === null) {
|
||||
$message['attachments'] = [];
|
||||
}
|
||||
|
||||
if (in_array($message['message_id'], $deleted)) {
|
||||
// If it exists, delete the matching record from the 'emails' table
|
||||
Email::where('message_id', $message['message_id'])->delete();
|
||||
if (config('app.fetch_from_remote_db')) {
|
||||
RemoteEmail::where('message_id', $message['message_id'])->delete();
|
||||
} else {
|
||||
Email::where('message_id', $message['message_id'])->delete();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -331,7 +344,11 @@ class Email extends Model
|
||||
file_put_contents(storage_path('logs/zemail.csv'), request()->ip() . "," . date("Y-m-d h:i:s a") . "," . $obj['sender_email'] . "," . $email . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
Email::where('message_id', $message['message_id'])->update(['is_seen' => true]);
|
||||
if (config('app.fetch_from_remote_db')) {
|
||||
RemoteEmail::where('message_id', $message['message_id'])->update(['is_seen' => true]);
|
||||
} else {
|
||||
Email::where('message_id', $message['message_id'])->update(['is_seen' => true]);
|
||||
}
|
||||
if (++$count > $limit) {
|
||||
break;
|
||||
}
|
||||
@@ -404,7 +421,11 @@ class Email extends Model
|
||||
|
||||
public static function mailToDBStatus(): bool
|
||||
{
|
||||
$latestRecord = self::orderBy('timestamp', 'desc')->first();
|
||||
if (config('app.fetch_from_remote_db')) {
|
||||
$latestRecord = RemoteEmail::orderBy('timestamp', 'desc')->first();
|
||||
} else {
|
||||
$latestRecord = self::orderBy('timestamp', 'desc')->first();
|
||||
}
|
||||
if (!$latestRecord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,16 +7,22 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class Plan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'product_id',
|
||||
'pricing_id',
|
||||
'price',
|
||||
'mailbox_limit',
|
||||
'monthly_billing',
|
||||
'details'
|
||||
'name',
|
||||
'description',
|
||||
'product_id',
|
||||
'pricing_id',
|
||||
'shoppy_product_id',
|
||||
'accept_stripe',
|
||||
'accept_shoppy',
|
||||
'oxapay_link',
|
||||
'accept_oxapay',
|
||||
'price',
|
||||
'mailbox_limit',
|
||||
'monthly_billing',
|
||||
'details',
|
||||
];
|
||||
|
||||
|
||||
protected $casts = [
|
||||
'details' => 'json',
|
||||
'monthly_billing' => 'boolean',
|
||||
|
||||
@@ -7,6 +7,7 @@ use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use function Laravel\Prompts\confirm;
|
||||
|
||||
class PremiumEmail extends Model
|
||||
{
|
||||
@@ -44,7 +45,11 @@ class PremiumEmail extends Model
|
||||
public static function createEmail($message, $email): void
|
||||
{
|
||||
$initialData = $message;
|
||||
$utcTime = CarbonImmutable::instance($message['timestamp'])->setTimezone('UTC')->toDateTimeString();
|
||||
if (config('app.fetch_from_db') && config('app.fetch_from_remote_db')) {
|
||||
$utcTime = CarbonImmutable::parse($message['timestamp'])->setTimezone('UTC')->toDateTimeString();
|
||||
} else {
|
||||
$utcTime = CarbonImmutable::instance($message['timestamp'])->setTimezone('UTC')->toDateTimeString();
|
||||
}
|
||||
$messageId = Carbon::parse($utcTime)->format('Ymd').$initialData['id'];
|
||||
$userId = \auth()->user()->id;
|
||||
$exists = PremiumEmail::where('user_id', $userId)->where('message_id', $messageId)->exists();
|
||||
|
||||
24
app/Models/RemoteEmail.php
Normal file
24
app/Models/RemoteEmail.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class RemoteEmail extends Model
|
||||
{
|
||||
protected $connection = 'mysql_remote';
|
||||
protected $table = 'emails';
|
||||
|
||||
public static function fetchEmailFromDB($email)
|
||||
{
|
||||
$validator = Validator::make(['email' => $email], [
|
||||
'email' => 'required|email'
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return [];
|
||||
}
|
||||
return self::whereJsonContains('to', $email)->orderBy('timestamp', 'desc')->get();
|
||||
}
|
||||
}
|
||||
57
app/Models/Ticket.php
Normal file
57
app/Models/Ticket.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Ticket extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'ticket_id', 'subject', 'message', 'status', 'last_response_at', 'ip_address'
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function responses()
|
||||
{
|
||||
return $this->hasMany(TicketResponse::class);
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime', // Ensures created_at is a Carbon instance
|
||||
'updated_at' => 'datetime', // Ensures updated_at is a Carbon instance
|
||||
'last_response_at' => 'datetime', // Cast last_response_at to Carbon instance
|
||||
];
|
||||
|
||||
public static function autoClose(): bool
|
||||
{
|
||||
try {
|
||||
$tickets = Ticket::where('status', 'pending')
|
||||
->where('last_response_at', '<', now()->subDays(3))
|
||||
->get();
|
||||
if (count($tickets) > 0) {
|
||||
foreach ($tickets as $ticket) {
|
||||
$ticket->status = 'closed';
|
||||
$ticket->save();
|
||||
|
||||
TicketResponse::create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'user_id' => 1,
|
||||
'response' => 'This ticket has been auto-closed due to inactivity.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
25
app/Models/TicketResponse.php
Normal file
25
app/Models/TicketResponse.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TicketResponse extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'ticket_id', 'user_id', 'response', 'ip_address'
|
||||
];
|
||||
|
||||
public function ticket()
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,18 @@ namespace App\Models;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable implements FilamentUser, MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, Billable;
|
||||
use HasFactory, Notifiable, Billable, HasApiTokens;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -63,4 +65,19 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
|
||||
{
|
||||
return str_ends_with($this->email, '@zemail.me') && $this->level === 9 && $this->hasVerifiedEmail();
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
|
||||
public function logs()
|
||||
{
|
||||
return $this->hasMany(Log::class);
|
||||
}
|
||||
|
||||
public function usageLogs()
|
||||
{
|
||||
return $this->hasMany(UsageLog::class);
|
||||
}
|
||||
}
|
||||
|
||||
32
app/NotifyMe.php
Normal file
32
app/NotifyMe.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
trait NotifyMe
|
||||
{
|
||||
function sendTelegramNotification($message) {
|
||||
$botToken = config('app.notify_tg_bot_token');
|
||||
$chatId = config('app.notify_tg_chat_id');
|
||||
|
||||
if (!$botToken || !$chatId) {
|
||||
\Log::error('Telegram bot token or chat ID not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";
|
||||
|
||||
$data = [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $message,
|
||||
'parse_mode' => 'HTML'
|
||||
];
|
||||
|
||||
try {
|
||||
$response = \Http::post($url, $data);
|
||||
return $response->successful();
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to send Telegram notification: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
@@ -16,6 +17,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'stripe/*',
|
||||
'webhook/oxapay',
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
|
||||
24
closeTicket.php
Normal file
24
closeTicket.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
// Adjust this path if your file location is different
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
// Bootstrap the Laravel application
|
||||
$app = require_once __DIR__ . '/bootstrap/app.php';
|
||||
|
||||
// Make the Console Kernel instance
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
|
||||
try {
|
||||
// Run the Artisan command 'ping'
|
||||
$exitCode = $kernel->call('closeTicket');
|
||||
|
||||
// Get the output of the command
|
||||
$output = $kernel->output();
|
||||
|
||||
echo "Artisan command 'closeTicket' executed successfully. Exit code: $exitCode\n";
|
||||
echo "Output:\n$output";
|
||||
|
||||
} catch (\Exception $e) {
|
||||
echo "Error running Artisan command: " . $e->getMessage();
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"filament/filament": "3.3",
|
||||
"laravel/cashier": "^15.6",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"livewire/flux": "^2.1",
|
||||
"livewire/livewire": "^3.6",
|
||||
|
||||
66
composer.lock
generated
66
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "972e884837f3870524619dc37aa08d0f",
|
||||
"content-hash": "c28ee3c8ad2c6071685462887cfb5ee5",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -2462,6 +2462,70 @@
|
||||
},
|
||||
"time": "2025-02-11T13:34:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sanctum",
|
||||
"version": "v4.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/sanctum.git",
|
||||
"reference": "a360a6a1fd2400ead4eb9b6a9c1bb272939194f5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/sanctum/zipball/a360a6a1fd2400ead4eb9b6a9c1bb272939194f5",
|
||||
"reference": "a360a6a1fd2400ead4eb9b6a9c1bb272939194f5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"illuminate/console": "^11.0|^12.0",
|
||||
"illuminate/contracts": "^11.0|^12.0",
|
||||
"illuminate/database": "^11.0|^12.0",
|
||||
"illuminate/support": "^11.0|^12.0",
|
||||
"php": "^8.2",
|
||||
"symfony/console": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^11.3"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Sanctum\\SanctumServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Sanctum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"laravel",
|
||||
"sanctum"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/sanctum/issues",
|
||||
"source": "https://github.com/laravel/sanctum"
|
||||
},
|
||||
"time": "2025-04-23T13:03:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v2.0.4",
|
||||
|
||||
@@ -31,10 +31,14 @@ return [
|
||||
'zemail_log' => env('ENABLE_ZEMAIL_LOGS', false),
|
||||
'beta_feature' => env('APP_BETA_FEATURE', false),
|
||||
'fetch_from_db' => env('FETCH_FETCH_FOR_DB', false),
|
||||
'fetch_from_remote_db' => env('FETCH_FROM_REMOTE_DB', false),
|
||||
'force_db_mail' => env('FORCE_DB_MAIL', false),
|
||||
'move_or_delete' => env('MOVE_OR_DELETE', null),
|
||||
'auto_fetch_mail' => env('AUTO_FETCH_MAIL', false),
|
||||
|
||||
'notify_tg_bot_token' => env('NOTIFY_TG_BOT_TOKEN', ''),
|
||||
'notify_tg_chat_id' => env('NOTIFY_TG_CHAT_ID', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|
||||
@@ -62,6 +62,26 @@ return [
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mysql_remote' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('REMOTE_DB_URL'),
|
||||
'host' => env('REMOTE_DB_HOST', '127.0.0.1'),
|
||||
'port' => env('REMOTE_DB_PORT', '3306'),
|
||||
'database' => env('REMOTE_DB_DATABASE', 'laravel'),
|
||||
'username' => env('REMOTE_DB_USERNAME', 'root'),
|
||||
'password' => env('REMOTE_DB_PASSWORD', ''),
|
||||
'unix_socket' => env('REMOTE_DB_SOCKET', ''),
|
||||
'charset' => env('REMOTE_DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('REMOTE_DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('REMOTE_MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
|
||||
84
config/sanctum.php
Normal file
84
config/sanctum.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort(),
|
||||
// Sanctum::currentRequestHost(),
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
||||
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
|
||||
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -34,5 +34,9 @@ return [
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
'oxapay' => [
|
||||
'merchant_api_key' => env('OXAPAY_MERCHANT_API_KEY', ''),
|
||||
'payout_api_key' => env('OXAPAY_PAYOUT_API_KEY', ''),
|
||||
]
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('activation_keys', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('activation_key')->unique();
|
||||
$table->string('price_id')->collation('utf8_bin');
|
||||
$table->boolean('is_activated')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('activation_keys');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plans', function (Blueprint $table) {
|
||||
$table->string('shoppy_product_id')->nullable()->after('pricing_id');
|
||||
$table->boolean('accept_stripe')->default(false)->after('shoppy_product_id');
|
||||
$table->boolean('accept_shoppy')->default(false)->after('accept_stripe');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plans', function (Blueprint $table) {
|
||||
$table->dropColumn(['shoppy_product_id', 'accept_stripe', 'accept_shoppy']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tickets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->string('ticket_id', 6)->unique();
|
||||
$table->string('subject');
|
||||
$table->text('message');
|
||||
$table->enum('status', ['open', 'closed', 'pending'])->default('open');
|
||||
$table->ipAddress()->nullable();
|
||||
$table->timestamp('last_response_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tickets');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ticket_responses', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->text('response');
|
||||
$table->ipAddress()->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_responses');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plans', function (Blueprint $table) {
|
||||
$table->string('oxapay_link')->nullable()->after('accept_shoppy');
|
||||
$table->boolean('accept_oxapay')->default(false)->after('oxapay_link');});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plans', function (Blueprint $table) {
|
||||
$table->dropColumn(['oxapay_link', 'accept_oxapay']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('tokenable');
|
||||
$table->string('name');
|
||||
$table->string('token', 64)->unique();
|
||||
$table->text('abilities')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('personal_access_tokens');
|
||||
}
|
||||
};
|
||||
28
database/seeders/UpdatePlansTableSeeder.php
Normal file
28
database/seeders/UpdatePlansTableSeeder.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Plan;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class UpdatePlansTableSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
Plan::where('id', 1)->update([
|
||||
'shoppy_product_id' => 'MsYfrRX',
|
||||
'accept_stripe' => 1,
|
||||
'accept_shoppy' => 1,
|
||||
]);
|
||||
|
||||
Plan::where('id', 2)->update([
|
||||
'shoppy_product_id' => '1oU5SNT',
|
||||
'accept_stripe' => 1,
|
||||
'accept_shoppy' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
569
public/css/silktide-consent-manager.css
Normal file
569
public/css/silktide-consent-manager.css
Normal file
@@ -0,0 +1,569 @@
|
||||
/*
|
||||
Silktide Consent Manager - https://silktide.com/consent-manager/
|
||||
|
||||
Styles are at risked of being overridden by styles coming from the site the consent manager is used on.
|
||||
To help prevent this, global wrapper elements are prefixed with "#silktide-"
|
||||
*/
|
||||
|
||||
/* --------------------------------
|
||||
Global Styles - These elements exist in the main DOM and styling is limited to positioning and animation
|
||||
-------------------------------- */
|
||||
/* Wrapper (Global) */
|
||||
#silktide-wrapper {
|
||||
/* Global */
|
||||
--focus: 0 0 0 2px #ffffff, 0 0 0 4px #000000, 0 0 0 6px #ffffff;
|
||||
--boxShadow: -5px 5px 10px 0px #00000012, 0px 0px 50px 0px #0000001a;
|
||||
--fontFamily: "Helvetica Neue", "Segoe UI", Arial, sans-serif;
|
||||
/* Color Scheme */
|
||||
--primaryColor: #533BE2;
|
||||
--backgroundColor: #FFFFFF;
|
||||
--textColor: #253B48;
|
||||
/* Backdrop */
|
||||
--backdropBackgroundColor: #00000033;
|
||||
--backdropBackgroundBlur: 0px;
|
||||
/* Cookie Icon */
|
||||
--cookieIconColor: #533BE2;
|
||||
--cookieIconBackgroundColor: #FFFFFF;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 99999;
|
||||
pointer-events: none;
|
||||
border: 0px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Backdrop (Global) */
|
||||
#silktide-backdrop-global {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: auto;
|
||||
border: 0px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
Links
|
||||
-------------------------------- */
|
||||
#silktide-wrapper a {
|
||||
all: unset;
|
||||
display: inline-block;
|
||||
color: var(--primaryColor);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#silktide-wrapper a:hover {
|
||||
cursor: pointer;
|
||||
color: var(--textColor);
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
Focus Styles
|
||||
-------------------------------- */
|
||||
#silktide-wrapper a:focus,
|
||||
#silktide-wrapper #silktide-banner button:focus,
|
||||
#silktide-wrapper #silktide-modal button:focus,
|
||||
#silktide-wrapper #silktide-cookie-icon:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--focus);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#silktide-wrapper #silktide-cookie-icon:focus {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
General Styles
|
||||
-------------------------------- */
|
||||
|
||||
#silktide-wrapper .st-button {
|
||||
color: var(--backgroundColor);
|
||||
background-color: var(--primaryColor);
|
||||
border: 2px solid var(--primaryColor);
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#silktide-wrapper .st-button--primary {
|
||||
}
|
||||
|
||||
#silktide-wrapper .st-button--primary:hover {
|
||||
background-color: var(--backgroundColor);
|
||||
color: var(--primaryColor);
|
||||
}
|
||||
|
||||
#silktide-wrapper .st-button--secondary {
|
||||
background-color: var(--backgroundColor);
|
||||
color: var(--primaryColor);
|
||||
}
|
||||
|
||||
#silktide-wrapper .st-button--secondary:hover {
|
||||
background-color: var(--primaryColor);
|
||||
color: var(--backgroundColor);
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
Banner
|
||||
-------------------------------- */
|
||||
#silktide-banner {
|
||||
font-family: var(--fontFamily);
|
||||
color: var(--textColor);
|
||||
background-color: var(--backgroundColor);
|
||||
box-sizing: border-box;
|
||||
padding: 32px;
|
||||
border-radius: 5px;
|
||||
pointer-events: auto;
|
||||
border: 0px;
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 600px;
|
||||
max-width: calc(100% - 32px);
|
||||
transform: translate(0, -20px);
|
||||
opacity: 0;
|
||||
animation: silktide-slideInDown 350ms ease-out forwards;
|
||||
animation-delay: 0.3s;
|
||||
box-shadow: -5px 5px 10px 0px #00000012, 0px 0px 50px 0px #0000001a;
|
||||
}
|
||||
|
||||
#silktide-banner:focus {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
#silktide-banner.center {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
position: fixed;
|
||||
transform: translate(-50%, calc(-50% - 20px));
|
||||
animation: silktide-slideInDown-center 350ms ease-out forwards;
|
||||
}
|
||||
|
||||
#silktide-banner.bottomLeft {
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
#silktide-banner.bottomCenter {
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
position: fixed;
|
||||
transform: translate(-50%, -20px);
|
||||
animation: silktide-slideInDown-bottomCenter 350ms ease-out forwards;
|
||||
}
|
||||
|
||||
#silktide-banner .preferences {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
border: none;
|
||||
padding: 0px;
|
||||
background-color: transparent;
|
||||
color: var(--primaryColor);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#silktide-banner .preferences span {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#silktide-banner .preferences span:hover {
|
||||
color: var(--textColor);
|
||||
}
|
||||
|
||||
#silktide-banner .preferences:after {
|
||||
display: block;
|
||||
content: '>';
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#silktide-banner p {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
margin: 0px 0px 15px;
|
||||
}
|
||||
|
||||
#silktide-banner a {
|
||||
display: inline-block;
|
||||
color: var(--primaryColor);
|
||||
text-decoration: underline;
|
||||
background-color: var(--backgroundColor);
|
||||
}
|
||||
|
||||
#silktide-banner a:hover {
|
||||
color: var(--textColor);
|
||||
}
|
||||
|
||||
#silktide-banner a.silktide-logo {
|
||||
display: block;
|
||||
fill: var(--primaryColor); /* passed down to svg > path */
|
||||
margin-left: auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
|
||||
#silktide-banner .actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
#silktide-banner .actions {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
#silktide-banner .actions-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
Modal
|
||||
-------------------------------- */
|
||||
#silktide-modal {
|
||||
display: none;
|
||||
pointer-events: auto;
|
||||
width: 800px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 0px;
|
||||
transform: translate(0px, -20px);
|
||||
opacity: 0;
|
||||
animation: silktide-slideInUp-center 350ms ease-out forwards;
|
||||
box-shadow: -5px 5px 10px 0px #00000012, 0px 0px 50px 0px #0000001a;
|
||||
font-family: var(--fontFamily);
|
||||
color: var(--textColor);
|
||||
flex-direction: column;
|
||||
padding: 30px;
|
||||
background-color: var(--backgroundColor);
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
Modal - Header
|
||||
-------------------------------- */
|
||||
#silktide-modal header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#silktide-modal h1 {
|
||||
font-family: var(--fontFamily);
|
||||
color: var(--textColor);
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
#silktide-modal .modal-close {
|
||||
display: inline-flex;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border: 0px;
|
||||
cursor: pointer;
|
||||
background: var(--backgroundColor);
|
||||
color: var(--primaryColor);
|
||||
}
|
||||
|
||||
#silktide-modal .modal-close svg {
|
||||
fill: var(--primaryColor);
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
Modal - Content
|
||||
-------------------------------- */
|
||||
|
||||
#silktide-modal section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 32px;
|
||||
padding-right: 7px; /* Prevents scrollbar from appearing over the switches */
|
||||
}
|
||||
|
||||
#silktide-modal section::-webkit-scrollbar {
|
||||
display: block; /* Force scrollbars to show */
|
||||
width: 5px; /* Width of the scrollbar */
|
||||
}
|
||||
|
||||
#silktide-modal section::-webkit-scrollbar-thumb {
|
||||
background-color: var(--textColor); /* Color of the scrollbar thumb */
|
||||
border-radius: 10px; /* Rounded corners for the thumb */
|
||||
}
|
||||
|
||||
#silktide-modal p {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: var(--textColor);
|
||||
margin: 0px 0px 15px;
|
||||
}
|
||||
|
||||
#silktide-modal p:last-of-type {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
#silktide-modal fieldset {
|
||||
padding: 0px;
|
||||
border: none;
|
||||
margin: 0px 0px 32px;
|
||||
}
|
||||
|
||||
#silktide-modal fieldset:last-of-type {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
#silktide-modal legend {
|
||||
padding: 0px;
|
||||
margin: 0px 0px 10px;
|
||||
font-weight: 700;
|
||||
color: var(--textColor);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#silktide-modal .cookie-type-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
Modal - Switches
|
||||
-------------------------------- */
|
||||
#silktide-modal .switch {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
height: 34px;
|
||||
width: 74px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#silktide-modal .switch:focus-within {
|
||||
outline: none;
|
||||
box-shadow: var(--focus);
|
||||
border-radius: 25px;
|
||||
}
|
||||
|
||||
#silktide-modal .switch input {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Unchecked Switch Styles */
|
||||
#silktide-modal .switch__pill {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 34px;
|
||||
width: 74px;
|
||||
background: var(--textColor);
|
||||
border-radius: 25px;
|
||||
}
|
||||
|
||||
#silktide-modal .switch__dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
display: block;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
background: var(--backgroundColor);
|
||||
border-radius: 50%;
|
||||
transition: left 150ms ease-out;
|
||||
}
|
||||
|
||||
#silktide-modal .switch__off,
|
||||
#silktide-modal .switch__on {
|
||||
text-transform: uppercase;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--backgroundColor);
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 8px;
|
||||
transition: right 150ms ease-out, opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
#silktide-modal .switch__off {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#silktide-modal .switch__on {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Checked Switch Styles */
|
||||
#silktide-modal .switch input:checked + .switch__pill {
|
||||
background: var(--primaryColor);
|
||||
}
|
||||
|
||||
#silktide-modal .switch input:checked ~ .switch__dot {
|
||||
left: calc(100% - 32px);
|
||||
}
|
||||
|
||||
#silktide-modal .switch input:checked ~ .switch__off {
|
||||
right: calc(100% - 32px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#silktide-modal .switch input:checked ~ .switch__on {
|
||||
right: calc(100% - 34px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Disabled Switch Styles */
|
||||
#silktide-modal .switch input:disabled + .switch__pill {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
Modal - Footer
|
||||
-------------------------------- */
|
||||
#silktide-modal footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
#silktide-modal footer {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
#silktide-modal footer a {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Cookie Icon */
|
||||
#silktide-cookie-icon {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
background-color: var(--cookieIconColor);
|
||||
cursor: pointer;
|
||||
box-shadow: 0px 0px 6px 0px #0000001a;
|
||||
pointer-events: auto;
|
||||
animation: silktide-fadeIn 0.3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
#silktide-cookie-icon.bottomRight {
|
||||
left: auto;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
#silktide-cookie-icon svg {
|
||||
fill: var(--cookieIconBackgroundColor);
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
Backdrop
|
||||
-------------------------------- */
|
||||
#silktide-backdrop {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--backdropBackgroundColor);
|
||||
backdrop-filter: blur(var(--backdropBackgroundBlur));
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* --------------------------------
|
||||
Animations
|
||||
-------------------------------- */
|
||||
@keyframes silktide-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes silktide-slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes silktide-slideInDown-center {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-50% - 20px));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes silktide-slideInDown-bottomCenter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes silktide-slideInUp-center {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(0px, 20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
850
public/js/silktide-consent-manager.js
Normal file
850
public/js/silktide-consent-manager.js
Normal file
@@ -0,0 +1,850 @@
|
||||
// Silktide Consent Manager - https://silktide.com/consent-manager/
|
||||
|
||||
class SilktideCookieBanner {
|
||||
constructor(config) {
|
||||
this.config = config; // Save config to the instance
|
||||
|
||||
this.wrapper = null;
|
||||
this.banner = null;
|
||||
this.modal = null;
|
||||
this.cookieIcon = null;
|
||||
this.backdrop = null;
|
||||
|
||||
this.createWrapper();
|
||||
|
||||
if (this.shouldShowBackdrop()) {
|
||||
this.createBackdrop();
|
||||
}
|
||||
|
||||
this.createCookieIcon();
|
||||
this.createModal();
|
||||
|
||||
if (this.shouldShowBanner()) {
|
||||
this.createBanner();
|
||||
this.showBackdrop();
|
||||
} else {
|
||||
this.showCookieIcon();
|
||||
}
|
||||
|
||||
this.setupEventListeners();
|
||||
|
||||
if (this.hasSetInitialCookieChoices()) {
|
||||
this.loadRequiredCookies();
|
||||
this.runAcceptedCookieCallbacks();
|
||||
}
|
||||
}
|
||||
|
||||
destroyCookieBanner() {
|
||||
// Remove all cookie banner elements from the DOM
|
||||
if (this.wrapper && this.wrapper.parentNode) {
|
||||
this.wrapper.parentNode.removeChild(this.wrapper);
|
||||
}
|
||||
|
||||
// Restore scrolling
|
||||
this.allowBodyScroll();
|
||||
|
||||
// Clear all references
|
||||
this.wrapper = null;
|
||||
this.banner = null;
|
||||
this.modal = null;
|
||||
this.cookieIcon = null;
|
||||
this.backdrop = null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Wrapper
|
||||
// ----------------------------------------------------------------
|
||||
createWrapper() {
|
||||
this.wrapper = document.createElement('div');
|
||||
this.wrapper.id = 'silktide-wrapper';
|
||||
document.body.insertBefore(this.wrapper, document.body.firstChild);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Wrapper Child Generator
|
||||
// ----------------------------------------------------------------
|
||||
createWrapperChild(htmlContent, id) {
|
||||
// Create child element
|
||||
const child = document.createElement('div');
|
||||
child.id = id;
|
||||
child.innerHTML = htmlContent;
|
||||
|
||||
// Ensure wrapper exists
|
||||
if (!this.wrapper || !document.body.contains(this.wrapper)) {
|
||||
this.createWrapper();
|
||||
}
|
||||
|
||||
// Append child to wrapper
|
||||
this.wrapper.appendChild(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Backdrop
|
||||
// ----------------------------------------------------------------
|
||||
createBackdrop() {
|
||||
this.backdrop = this.createWrapperChild(null, 'silktide-backdrop');
|
||||
}
|
||||
|
||||
showBackdrop() {
|
||||
if (this.backdrop) {
|
||||
this.backdrop.style.display = 'block';
|
||||
}
|
||||
// Trigger optional onBackdropOpen callback
|
||||
if (typeof this.config.onBackdropOpen === 'function') {
|
||||
this.config.onBackdropOpen();
|
||||
}
|
||||
}
|
||||
|
||||
hideBackdrop() {
|
||||
if (this.backdrop) {
|
||||
this.backdrop.style.display = 'none';
|
||||
}
|
||||
|
||||
// Trigger optional onBackdropClose callback
|
||||
if (typeof this.config.onBackdropClose === 'function') {
|
||||
this.config.onBackdropClose();
|
||||
}
|
||||
}
|
||||
|
||||
shouldShowBackdrop() {
|
||||
return this.config?.background?.showBackground || false;
|
||||
}
|
||||
|
||||
// update the checkboxes in the modal with the values from localStorage
|
||||
updateCheckboxState(saveToStorage = false) {
|
||||
const preferencesSection = this.modal.querySelector('#cookie-preferences');
|
||||
const checkboxes = preferencesSection.querySelectorAll('input[type="checkbox"]');
|
||||
|
||||
checkboxes.forEach((checkbox) => {
|
||||
const [, cookieId] = checkbox.id.split('cookies-');
|
||||
const cookieType = this.config.cookieTypes.find(type => type.id === cookieId);
|
||||
|
||||
if (!cookieType) return;
|
||||
|
||||
if (saveToStorage) {
|
||||
// Save the current state to localStorage and run callbacks
|
||||
const currentState = checkbox.checked;
|
||||
|
||||
if (cookieType.required) {
|
||||
localStorage.setItem(
|
||||
`silktideCookieChoice_${cookieId}${this.getBannerSuffix()}`,
|
||||
'true'
|
||||
);
|
||||
} else {
|
||||
localStorage.setItem(
|
||||
`silktideCookieChoice_${cookieId}${this.getBannerSuffix()}`,
|
||||
currentState.toString()
|
||||
);
|
||||
|
||||
// Run appropriate callback
|
||||
if (currentState && typeof cookieType.onAccept === 'function') {
|
||||
cookieType.onAccept();
|
||||
} else if (!currentState && typeof cookieType.onReject === 'function') {
|
||||
cookieType.onReject();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// When reading values (opening modal)
|
||||
if (cookieType.required) {
|
||||
checkbox.checked = true;
|
||||
checkbox.disabled = true;
|
||||
} else {
|
||||
const storedValue = localStorage.getItem(
|
||||
`silktideCookieChoice_${cookieId}${this.getBannerSuffix()}`
|
||||
);
|
||||
|
||||
if (storedValue !== null) {
|
||||
checkbox.checked = storedValue === 'true';
|
||||
} else {
|
||||
checkbox.checked = !!cookieType.defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setInitialCookieChoiceMade() {
|
||||
window.localStorage.setItem(`silktideCookieBanner_InitialChoice${this.getBannerSuffix()}`, 1);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Consent Handling
|
||||
// ----------------------------------------------------------------
|
||||
handleCookieChoice(accepted) {
|
||||
// We set that an initial choice was made regardless of what it was so we don't show the banner again
|
||||
this.setInitialCookieChoiceMade();
|
||||
|
||||
this.removeBanner();
|
||||
this.hideBackdrop();
|
||||
this.toggleModal(false);
|
||||
this.showCookieIcon();
|
||||
|
||||
this.config.cookieTypes.forEach((type) => {
|
||||
// Set localStorage and run accept/reject callbacks
|
||||
if (type.required == true) {
|
||||
localStorage.setItem(`silktideCookieChoice_${type.id}${this.getBannerSuffix()}`, 'true');
|
||||
if (typeof type.onAccept === 'function') { type.onAccept() }
|
||||
} else {
|
||||
localStorage.setItem(
|
||||
`silktideCookieChoice_${type.id}${this.getBannerSuffix()}`,
|
||||
accepted.toString(),
|
||||
);
|
||||
|
||||
if (accepted) {
|
||||
if (typeof type.onAccept === 'function') { type.onAccept(); }
|
||||
} else {
|
||||
if (typeof type.onReject === 'function') { type.onReject(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger optional onAcceptAll/onRejectAll callbacks
|
||||
if (accepted && typeof this.config.onAcceptAll === 'function') {
|
||||
if (typeof this.config.onAcceptAll === 'function') { this.config.onAcceptAll(); }
|
||||
} else if (typeof this.config.onRejectAll === 'function') {
|
||||
if (typeof this.config.onRejectAll === 'function') { this.config.onRejectAll(); }
|
||||
}
|
||||
|
||||
// finally update the checkboxes in the modal with the values from localStorage
|
||||
this.updateCheckboxState();
|
||||
}
|
||||
|
||||
getAcceptedCookies() {
|
||||
return (this.config.cookieTypes || []).reduce((acc, cookieType) => {
|
||||
acc[cookieType.id] =
|
||||
localStorage.getItem(`silktideCookieChoice_${cookieType.id}${this.getBannerSuffix()}`) ===
|
||||
'true';
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
runAcceptedCookieCallbacks() {
|
||||
if (!this.config.cookieTypes) return;
|
||||
|
||||
const acceptedCookies = this.getAcceptedCookies();
|
||||
this.config.cookieTypes.forEach((type) => {
|
||||
if (type.required) return; // we run required cookies separately in loadRequiredCookies
|
||||
if (acceptedCookies[type.id] && typeof type.onAccept === 'function') {
|
||||
if (typeof type.onAccept === 'function') { type.onAccept(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runRejectedCookieCallbacks() {
|
||||
if (!this.config.cookieTypes) return;
|
||||
|
||||
const rejectedCookies = this.getRejectedCookies();
|
||||
this.config.cookieTypes.forEach((type) => {
|
||||
if (rejectedCookies[type.id] && typeof type.onReject === 'function') {
|
||||
if (typeof type.onReject === 'function') { type.onReject(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run through all of the cookie callbacks based on the current localStorage values
|
||||
*/
|
||||
runStoredCookiePreferenceCallbacks() {
|
||||
this.config.cookieTypes.forEach((type) => {
|
||||
const accepted =
|
||||
localStorage.getItem(`silktideCookieChoice_${type.id}${this.getBannerSuffix()}`) === 'true';
|
||||
// Set localStorage and run accept/reject callbacks
|
||||
if (accepted) {
|
||||
if (typeof type.onAccept === 'function') { type.onAccept(); }
|
||||
} else {
|
||||
if (typeof type.onReject === 'function') { type.onReject(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadRequiredCookies() {
|
||||
if (!this.config.cookieTypes) return;
|
||||
this.config.cookieTypes.forEach((cookie) => {
|
||||
if (cookie.required && typeof cookie.onAccept === 'function') {
|
||||
if (typeof cookie.onAccept === 'function') { cookie.onAccept(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Banner
|
||||
// ----------------------------------------------------------------
|
||||
getBannerContent() {
|
||||
const bannerDescription =
|
||||
this.config.text?.banner?.description ||
|
||||
`<p>We use cookies on our site to enhance your user experience, provide personalized content, and analyze our traffic.</p>`;
|
||||
|
||||
// Accept button
|
||||
const acceptAllButtonText = this.config.text?.banner?.acceptAllButtonText || 'Accept all';
|
||||
const acceptAllButtonLabel = this.config.text?.banner?.acceptAllButtonAccessibleLabel;
|
||||
const acceptAllButton = `<button class="accept-all st-button st-button--primary"${
|
||||
acceptAllButtonLabel && acceptAllButtonLabel !== acceptAllButtonText
|
||||
? ` aria-label="${acceptAllButtonLabel}"`
|
||||
: ''
|
||||
}>${acceptAllButtonText}</button>`;
|
||||
|
||||
// Reject button
|
||||
const rejectNonEssentialButtonText = this.config.text?.banner?.rejectNonEssentialButtonText || 'Reject non-essential';
|
||||
const rejectNonEssentialButtonLabel = this.config.text?.banner?.rejectNonEssentialButtonAccessibleLabel;
|
||||
const rejectNonEssentialButton = `<button class="reject-all st-button st-button--primary"${
|
||||
rejectNonEssentialButtonLabel && rejectNonEssentialButtonLabel !== rejectNonEssentialButtonText
|
||||
? ` aria-label="${rejectNonEssentialButtonLabel}"`
|
||||
: ''
|
||||
}>${rejectNonEssentialButtonText}</button>`;
|
||||
|
||||
// Preferences button
|
||||
const preferencesButtonText = this.config.text?.banner?.preferencesButtonText || 'Preferences';
|
||||
const preferencesButtonLabel = this.config.text?.banner?.preferencesButtonAccessibleLabel;
|
||||
const preferencesButton = `<button class="preferences"${
|
||||
preferencesButtonLabel && preferencesButtonLabel !== preferencesButtonText
|
||||
? ` aria-label="${preferencesButtonLabel}"`
|
||||
: ''
|
||||
}><span>${preferencesButtonText}</span></button>`;
|
||||
|
||||
|
||||
// Silktide logo link
|
||||
const silktideLogo = `
|
||||
<a class="silktide-logo" href="https://silktide.com/consent-manager" aria-label="Visit the Silktide Consent Manager page">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="25" viewBox="0 0 24 25" fill="inherit">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.1096 16.7745C13.8895 17.2055 13.3537 17.3805 12.9129 17.1653L8.28443 14.9055L2.73192 17.7651L11.1025 21.9814C11.909 22.3876 12.8725 22.3591 13.6524 21.9058L20.4345 17.9645C21.2845 17.4704 21.7797 16.5522 21.7164 15.5872L21.7088 15.4704C21.6487 14.5561 21.0962 13.7419 20.2579 13.3326L15.6793 11.0972L10.2283 13.9045L13.71 15.6043C14.1507 15.8195 14.3297 16.3434 14.1096 16.7745ZM8.2627 12.9448L13.7136 10.1375L10.2889 8.46543C9.84803 8.25021 9.66911 7.72629 9.88916 7.29524C10.1093 6.86417 10.6451 6.68921 11.0859 6.90442L15.6575 9.13647L21.2171 6.27325L12.8808 2.03496C12.0675 1.62147 11.0928 1.65154 10.3078 2.11432L3.54908 6.09869C2.70732 6.59492 2.21846 7.50845 2.28139 8.46761L2.29003 8.59923C2.35002 9.51362 2.9026 10.3278 3.7409 10.7371L8.2627 12.9448ZM6.31884 13.9458L2.94386 12.2981C1.53727 11.6113 0.610092 10.2451 0.509431 8.71094L0.500795 8.57933C0.3952 6.96993 1.21547 5.4371 2.62787 4.60447L9.38662 0.620092C10.7038 -0.156419 12.3392 -0.206861 13.7039 0.486938L23.3799 5.40639C23.4551 5.44459 23.5224 5.4918 23.5811 5.54596C23.7105 5.62499 23.8209 5.73754 23.897 5.87906C24.1266 6.30534 23.9594 6.83293 23.5234 7.05744L17.6231 10.0961L21.0549 11.7716C22.4615 12.4583 23.3887 13.8245 23.4893 15.3587L23.497 15.4755C23.6033 17.0947 22.7724 18.6354 21.346 19.4644L14.5639 23.4057C13.2554 24.1661 11.6386 24.214 10.2854 23.5324L0.621855 18.6649C0.477299 18.592 0.361696 18.4859 0.279794 18.361C0.210188 18.2968 0.150054 18.2204 0.10296 18.133C-0.126635 17.7067 0.0406445 17.1792 0.47659 16.9546L6.31884 13.9458Z" fill="inherit"/>
|
||||
</svg>
|
||||
</a>
|
||||
`;
|
||||
|
||||
const bannerContent = `
|
||||
${bannerDescription}
|
||||
<div class="actions">
|
||||
${acceptAllButton}
|
||||
${rejectNonEssentialButton}
|
||||
<div class="actions-row">
|
||||
${preferencesButton}
|
||||
${silktideLogo}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return bannerContent;
|
||||
}
|
||||
|
||||
hasSetInitialCookieChoices() {
|
||||
return !!localStorage.getItem(`silktideCookieBanner_InitialChoice${this.getBannerSuffix()}`);
|
||||
}
|
||||
|
||||
createBanner() {
|
||||
// Create banner element
|
||||
this.banner = this.createWrapperChild(this.getBannerContent(), 'silktide-banner');
|
||||
|
||||
// Add positioning class from config
|
||||
if (this.banner && this.config.position?.banner) {
|
||||
this.banner.classList.add(this.config.position.banner);
|
||||
}
|
||||
|
||||
// Trigger optional onBannerOpen callback
|
||||
if (this.banner && typeof this.config.onBannerOpen === 'function') {
|
||||
this.config.onBannerOpen();
|
||||
}
|
||||
}
|
||||
|
||||
removeBanner() {
|
||||
if (this.banner && this.banner.parentNode) {
|
||||
this.banner.parentNode.removeChild(this.banner);
|
||||
this.banner = null;
|
||||
|
||||
// Trigger optional onBannerClose callback
|
||||
if (typeof this.config.onBannerClose === 'function') {
|
||||
this.config.onBannerClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shouldShowBanner() {
|
||||
if (this.config.showBanner === false) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
localStorage.getItem(`silktideCookieBanner_InitialChoice${this.getBannerSuffix()}`) === null
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Modal
|
||||
// ----------------------------------------------------------------
|
||||
getModalContent() {
|
||||
const preferencesTitle =
|
||||
this.config.text?.preferences?.title || 'Customize your cookie preferences';
|
||||
|
||||
const preferencesDescription =
|
||||
this.config.text?.preferences?.description ||
|
||||
`<p>We respect your right to privacy. You can choose not to allow some types of cookies. Your cookie preferences will apply across our website.</p>`;
|
||||
|
||||
// Preferences button
|
||||
const preferencesButtonLabel = this.config.text?.banner?.preferencesButtonAccessibleLabel;
|
||||
|
||||
const closeModalButton = `<button class="modal-close"${preferencesButtonLabel ? ` aria-label="${preferencesButtonLabel}"` : ''}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.4081 3.41559C20.189 2.6347 20.189 1.36655 19.4081 0.585663C18.6272 -0.195221 17.3591 -0.195221 16.5782 0.585663L10 7.17008L3.41559 0.59191C2.6347 -0.188974 1.36655 -0.188974 0.585663 0.59191C-0.195221 1.37279 -0.195221 2.64095 0.585663 3.42183L7.17008 10L0.59191 16.5844C-0.188974 17.3653 -0.188974 18.6335 0.59191 19.4143C1.37279 20.1952 2.64095 20.1952 3.42183 19.4143L10 12.8299L16.5844 19.4081C17.3653 20.189 18.6335 20.189 19.4143 19.4081C20.1952 18.6272 20.1952 17.3591 19.4143 16.5782L12.8299 10L19.4081 3.41559Z"/>
|
||||
</svg>
|
||||
</button>`;
|
||||
|
||||
|
||||
const cookieTypes = this.config.cookieTypes || [];
|
||||
const acceptedCookieMap = this.getAcceptedCookies();
|
||||
|
||||
// Accept button
|
||||
const acceptAllButtonText = this.config.text?.banner?.acceptAllButtonText || 'Accept all';
|
||||
const acceptAllButtonLabel = this.config.text?.banner?.acceptAllButtonAccessibleLabel;
|
||||
const acceptAllButton = `<button class="preferences-accept-all st-button st-button--primary"${
|
||||
acceptAllButtonLabel && acceptAllButtonLabel !== acceptAllButtonText
|
||||
? ` aria-label="${acceptAllButtonLabel}"`
|
||||
: ''
|
||||
}>${acceptAllButtonText}</button>`;
|
||||
|
||||
// Reject button
|
||||
const rejectNonEssentialButtonText = this.config.text?.banner?.rejectNonEssentialButtonText || 'Reject non-essential';
|
||||
const rejectNonEssentialButtonLabel = this.config.text?.banner?.rejectNonEssentialButtonAccessibleLabel;
|
||||
const rejectNonEssentialButton = `<button class="preferences-reject-all st-button st-button--primary"${
|
||||
rejectNonEssentialButtonLabel && rejectNonEssentialButtonLabel !== rejectNonEssentialButtonText
|
||||
? ` aria-label="${rejectNonEssentialButtonLabel}"`
|
||||
: ''
|
||||
}>${rejectNonEssentialButtonText}</button>`;
|
||||
|
||||
// Credit link
|
||||
const creditLinkText = this.config.text?.preferences?.creditLinkText || 'Get this banner for free';
|
||||
const creditLinkAccessibleLabel = this.config.text?.preferences?.creditLinkAccessibleLabel;
|
||||
const creditLink = `<a href="https://silktide.com/consent-manager"${
|
||||
creditLinkAccessibleLabel && creditLinkAccessibleLabel !== creditLinkText
|
||||
? ` aria-label="${creditLinkAccessibleLabel}"`
|
||||
: ''
|
||||
}>${creditLinkText}</a>`;
|
||||
|
||||
|
||||
|
||||
const modalContent = `
|
||||
<header>
|
||||
<h1>${preferencesTitle}</h1>
|
||||
${closeModalButton}
|
||||
</header>
|
||||
${preferencesDescription}
|
||||
<section id="cookie-preferences">
|
||||
${cookieTypes
|
||||
.map((type) => {
|
||||
const accepted = acceptedCookieMap[type.id];
|
||||
let isChecked = false;
|
||||
|
||||
// if it's accepted then show as checked
|
||||
if (accepted) {
|
||||
isChecked = true;
|
||||
}
|
||||
|
||||
// if nothing has been accepted / rejected yet, then show as checked if the default value is true
|
||||
if (!accepted && !this.hasSetInitialCookieChoices()) {
|
||||
isChecked = type.defaultValue;
|
||||
}
|
||||
|
||||
return `
|
||||
<fieldset>
|
||||
<legend>${type.name}</legend>
|
||||
<div class="cookie-type-content">
|
||||
<div class="cookie-type-description">${type.description}</div>
|
||||
<label class="switch" for="cookies-${type.id}">
|
||||
<input type="checkbox" id="cookies-${type.id}" ${
|
||||
type.required ? 'checked disabled' : isChecked ? 'checked' : ''
|
||||
} />
|
||||
<span class="switch__pill" aria-hidden="true"></span>
|
||||
<span class="switch__dot" aria-hidden="true"></span>
|
||||
<span class="switch__off" aria-hidden="true">Off</span>
|
||||
<span class="switch__on" aria-hidden="true">On</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
`;
|
||||
})
|
||||
.join('')}
|
||||
</section>
|
||||
<footer>
|
||||
${acceptAllButton}
|
||||
${rejectNonEssentialButton}
|
||||
${creditLink}
|
||||
</footer>
|
||||
`;
|
||||
|
||||
return modalContent;
|
||||
}
|
||||
|
||||
createModal() {
|
||||
// Create banner element
|
||||
this.modal = this.createWrapperChild(this.getModalContent(), 'silktide-modal');
|
||||
}
|
||||
|
||||
toggleModal(show) {
|
||||
if (!this.modal) return;
|
||||
|
||||
this.modal.style.display = show ? 'flex' : 'none';
|
||||
|
||||
if (show) {
|
||||
this.showBackdrop();
|
||||
this.hideCookieIcon();
|
||||
this.removeBanner();
|
||||
this.preventBodyScroll();
|
||||
|
||||
// Focus the close button
|
||||
const modalCloseButton = this.modal.querySelector('.modal-close');
|
||||
modalCloseButton.focus();
|
||||
|
||||
// Trigger optional onPreferencesOpen callback
|
||||
if (typeof this.config.onPreferencesOpen === 'function') {
|
||||
this.config.onPreferencesOpen();
|
||||
}
|
||||
|
||||
this.updateCheckboxState(false); // read from storage when opening
|
||||
} else {
|
||||
// Set that an initial choice was made when closing the modal
|
||||
this.setInitialCookieChoiceMade();
|
||||
|
||||
// Save current checkbox states to storage
|
||||
this.updateCheckboxState(true);
|
||||
|
||||
this.hideBackdrop();
|
||||
this.showCookieIcon();
|
||||
this.allowBodyScroll();
|
||||
|
||||
// Trigger optional onPreferencesClose callback
|
||||
if (typeof this.config.onPreferencesClose === 'function') {
|
||||
this.config.onPreferencesClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Cookie Icon
|
||||
// ----------------------------------------------------------------
|
||||
getCookieIconContent() {
|
||||
return `
|
||||
<svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.1172 1.15625C19.0547 0.734374 18.7344 0.390624 18.3125 0.328124C16.5859 0.0859365 14.8281 0.398437 13.2813 1.21875L7.5 4.30469C5.96094 5.125 4.71875 6.41406 3.95313 7.98437L1.08594 13.8906C0.320314 15.4609 0.0703136 17.2422 0.375001 18.9609L1.50781 25.4297C1.8125 27.1562 2.64844 28.7344 3.90625 29.9531L8.61719 34.5156C9.875 35.7344 11.4766 36.5156 13.2031 36.7578L19.6875 37.6719C21.4141 37.9141 23.1719 37.6016 24.7188 36.7812L30.5 33.6953C32.0391 32.875 33.2813 31.5859 34.0469 30.0078L36.9141 24.1094C37.6797 22.5391 37.9297 20.7578 37.625 19.0391C37.5547 18.625 37.2109 18.3125 36.7969 18.25C32.7734 17.6094 29.5469 14.5703 28.6328 10.6406C28.4922 10.0469 28.0078 9.59375 27.4063 9.5C23.1406 8.82031 19.7734 5.4375 19.1094 1.15625H19.1172ZM15.25 10.25C15.913 10.25 16.5489 10.5134 17.0178 10.9822C17.4866 11.4511 17.75 12.087 17.75 12.75C17.75 13.413 17.4866 14.0489 17.0178 14.5178C16.5489 14.9866 15.913 15.25 15.25 15.25C14.587 15.25 13.9511 14.9866 13.4822 14.5178C13.0134 14.0489 12.75 13.413 12.75 12.75C12.75 12.087 13.0134 11.4511 13.4822 10.9822C13.9511 10.5134 14.587 10.25 15.25 10.25ZM10.25 25.25C10.25 24.587 10.5134 23.9511 10.9822 23.4822C11.4511 23.0134 12.087 22.75 12.75 22.75C13.413 22.75 14.0489 23.0134 14.5178 23.4822C14.9866 23.9511 15.25 24.587 15.25 25.25C15.25 25.913 14.9866 26.5489 14.5178 27.0178C14.0489 27.4866 13.413 27.75 12.75 27.75C12.087 27.75 11.4511 27.4866 10.9822 27.0178C10.5134 26.5489 10.25 25.913 10.25 25.25ZM27.75 20.25C28.413 20.25 29.0489 20.5134 29.5178 20.9822C29.9866 21.4511 30.25 22.087 30.25 22.75C30.25 23.413 29.9866 24.0489 29.5178 24.5178C29.0489 24.9866 28.413 25.25 27.75 25.25C27.087 25.25 26.4511 24.9866 25.9822 24.5178C25.5134 24.0489 25.25 23.413 25.25 22.75C25.25 22.087 25.5134 21.4511 25.9822 20.9822C26.4511 20.5134 27.087 20.25 27.75 20.25Z" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
createCookieIcon() {
|
||||
this.cookieIcon = document.createElement('button');
|
||||
this.cookieIcon.id = 'silktide-cookie-icon';
|
||||
this.cookieIcon.innerHTML = this.getCookieIconContent();
|
||||
|
||||
if (this.config.text?.banner?.preferencesButtonAccessibleLabel) {
|
||||
this.cookieIcon.ariaLabel = this.config.text?.banner?.preferencesButtonAccessibleLabel;
|
||||
}
|
||||
|
||||
// Ensure wrapper exists
|
||||
if (!this.wrapper || !document.body.contains(this.wrapper)) {
|
||||
this.createWrapper();
|
||||
}
|
||||
|
||||
// Append child to wrapper
|
||||
this.wrapper.appendChild(this.cookieIcon);
|
||||
|
||||
// Add positioning class from config
|
||||
if (this.cookieIcon && this.config.cookieIcon?.position) {
|
||||
this.cookieIcon.classList.add(this.config.cookieIcon.position);
|
||||
}
|
||||
|
||||
// Add color scheme class from config
|
||||
if (this.cookieIcon && this.config.cookieIcon?.colorScheme) {
|
||||
this.cookieIcon.classList.add(this.config.cookieIcon.colorScheme);
|
||||
}
|
||||
}
|
||||
|
||||
showCookieIcon() {
|
||||
if (this.cookieIcon) {
|
||||
this.cookieIcon.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
hideCookieIcon() {
|
||||
if (this.cookieIcon) {
|
||||
this.cookieIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This runs if the user closes the modal without making a choice for the first time
|
||||
* We apply the default values and the necessary values as default
|
||||
*/
|
||||
handleClosedWithNoChoice() {
|
||||
this.config.cookieTypes.forEach((type) => {
|
||||
let accepted = true;
|
||||
// Set localStorage and run accept/reject callbacks
|
||||
if (type.required == true) {
|
||||
localStorage.setItem(
|
||||
`silktideCookieChoice_${type.id}${this.getBannerSuffix()}`,
|
||||
accepted.toString(),
|
||||
);
|
||||
} else if (type.defaultValue) {
|
||||
localStorage.setItem(
|
||||
`silktideCookieChoice_${type.id}${this.getBannerSuffix()}`,
|
||||
accepted.toString(),
|
||||
);
|
||||
} else {
|
||||
accepted = false;
|
||||
localStorage.setItem(
|
||||
`silktideCookieChoice_${type.id}${this.getBannerSuffix()}`,
|
||||
accepted.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
if (accepted) {
|
||||
if (typeof type.onAccept === 'function') { type.onAccept(); }
|
||||
} else {
|
||||
if (typeof type.onReject === 'function') { type.onReject(); }
|
||||
}
|
||||
// set the flag to say that the cookie choice has been made
|
||||
this.setInitialCookieChoiceMade();
|
||||
this.updateCheckboxState();
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Focusable Elements
|
||||
// ----------------------------------------------------------------
|
||||
getFocusableElements(element) {
|
||||
return element.querySelectorAll(
|
||||
'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Event Listeners
|
||||
// ----------------------------------------------------------------
|
||||
setupEventListeners() {
|
||||
// Check Banner exists before trying to add event listeners
|
||||
if (this.banner) {
|
||||
// Get the buttons
|
||||
const acceptButton = this.banner.querySelector('.accept-all');
|
||||
const rejectButton = this.banner.querySelector('.reject-all');
|
||||
const preferencesButton = this.banner.querySelector('.preferences');
|
||||
|
||||
// Add event listeners to the buttons
|
||||
acceptButton?.addEventListener('click', () => this.handleCookieChoice(true));
|
||||
rejectButton?.addEventListener('click', () => this.handleCookieChoice(false));
|
||||
preferencesButton?.addEventListener('click', () => {
|
||||
this.showBackdrop();
|
||||
this.toggleModal(true);
|
||||
});
|
||||
|
||||
// Focus Trap
|
||||
const focusableElements = this.getFocusableElements(this.banner);
|
||||
const firstFocusableEl = focusableElements[0];
|
||||
const lastFocusableEl = focusableElements[focusableElements.length - 1];
|
||||
|
||||
// Add keydown event listener to handle tab navigation
|
||||
this.banner.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstFocusableEl) {
|
||||
lastFocusableEl.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastFocusableEl) {
|
||||
firstFocusableEl.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial focus
|
||||
if (this.config.mode !== 'wizard') {
|
||||
acceptButton?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Check Modal exists before trying to add event listeners
|
||||
if (this.modal) {
|
||||
const closeButton = this.modal.querySelector('.modal-close');
|
||||
const acceptAllButton = this.modal.querySelector('.preferences-accept-all');
|
||||
const rejectAllButton = this.modal.querySelector('.preferences-reject-all');
|
||||
|
||||
closeButton?.addEventListener('click', () => {
|
||||
this.toggleModal(false);
|
||||
|
||||
const hasMadeFirstChoice = this.hasSetInitialCookieChoices();
|
||||
|
||||
if (hasMadeFirstChoice) {
|
||||
// run through the callbacks based on the current localStorage state
|
||||
this.runStoredCookiePreferenceCallbacks();
|
||||
} else {
|
||||
// handle the case where the user closes without making a choice for the first time
|
||||
this.handleClosedWithNoChoice();
|
||||
}
|
||||
});
|
||||
acceptAllButton?.addEventListener('click', () => this.handleCookieChoice(true));
|
||||
rejectAllButton?.addEventListener('click', () => this.handleCookieChoice(false));
|
||||
|
||||
// Banner Focus Trap
|
||||
const focusableElements = this.getFocusableElements(this.modal);
|
||||
const firstFocusableEl = focusableElements[0];
|
||||
const lastFocusableEl = focusableElements[focusableElements.length - 1];
|
||||
|
||||
this.modal.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstFocusableEl) {
|
||||
lastFocusableEl.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastFocusableEl) {
|
||||
firstFocusableEl.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
this.toggleModal(false);
|
||||
}
|
||||
});
|
||||
|
||||
closeButton?.focus();
|
||||
|
||||
// Update the checkbox event listeners
|
||||
const preferencesSection = this.modal.querySelector('#cookie-preferences');
|
||||
const checkboxes = preferencesSection.querySelectorAll('input[type="checkbox"]');
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', (event) => {
|
||||
const [, cookieId] = event.target.id.split('cookies-');
|
||||
const isAccepted = event.target.checked;
|
||||
const previousValue = localStorage.getItem(
|
||||
`silktideCookieChoice_${cookieId}${this.getBannerSuffix()}`
|
||||
) === 'true';
|
||||
|
||||
// Only proceed if the value has actually changed
|
||||
if (isAccepted !== previousValue) {
|
||||
// Find the corresponding cookie type
|
||||
const cookieType = this.config.cookieTypes.find(type => type.id === cookieId);
|
||||
|
||||
if (cookieType) {
|
||||
// Update localStorage
|
||||
localStorage.setItem(
|
||||
`silktideCookieChoice_${cookieId}${this.getBannerSuffix()}`,
|
||||
isAccepted.toString()
|
||||
);
|
||||
|
||||
// Run the appropriate callback only if the value changed
|
||||
if (isAccepted && typeof cookieType.onAccept === 'function') {
|
||||
cookieType.onAccept();
|
||||
} else if (!isAccepted && typeof cookieType.onReject === 'function') {
|
||||
cookieType.onReject();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check Cookie Icon exists before trying to add event listeners
|
||||
if (this.cookieIcon) {
|
||||
|
||||
this.cookieIcon.addEventListener('click', () => {
|
||||
// If modal is not found, create it
|
||||
if (!this.modal) {
|
||||
this.createModal();
|
||||
this.toggleModal(true);
|
||||
this.hideCookieIcon();
|
||||
}
|
||||
// If modal is hidden, show it
|
||||
else if (this.modal.style.display === 'none' || this.modal.style.display === '') {
|
||||
this.toggleModal(true);
|
||||
this.hideCookieIcon();
|
||||
}
|
||||
// If modal is visible, hide it
|
||||
else {
|
||||
this.toggleModal(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getBannerSuffix() {
|
||||
if (this.config.bannerSuffix) {
|
||||
return '_' + this.config.bannerSuffix;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
preventBodyScroll() {
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Prevent iOS Safari scrolling
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
|
||||
allowBodyScroll() {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
}
|
||||
|
||||
(function () {
|
||||
window.silktideCookieBannerManager = {};
|
||||
|
||||
let config = {};
|
||||
let cookieBanner;
|
||||
|
||||
function updateCookieBannerConfig(userConfig = {}) {
|
||||
config = {...config, ...userConfig};
|
||||
|
||||
// If cookie banner exists, destroy and recreate it with new config
|
||||
if (cookieBanner) {
|
||||
cookieBanner.destroyCookieBanner(); // We'll need to add this method
|
||||
cookieBanner = null;
|
||||
}
|
||||
|
||||
// Only initialize if document.body exists
|
||||
if (document.body) {
|
||||
initCookieBanner();
|
||||
} else {
|
||||
// Wait for DOM to be ready
|
||||
document.addEventListener('DOMContentLoaded', initCookieBanner, {once: true});
|
||||
}
|
||||
}
|
||||
|
||||
function initCookieBanner() {
|
||||
if (!cookieBanner) {
|
||||
cookieBanner = new SilktideCookieBanner(config); // Pass config to the CookieBanner instance
|
||||
}
|
||||
}
|
||||
|
||||
function injectScript(url, loadOption) {
|
||||
// Check if script with this URL already exists
|
||||
const existingScript = document.querySelector(`script[src="${url}"]`);
|
||||
if (existingScript) {
|
||||
return; // Script already exists, don't add it again
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
|
||||
// Apply the async or defer attribute based on the loadOption parameter
|
||||
if (loadOption === 'async') {
|
||||
script.async = true;
|
||||
} else if (loadOption === 'defer') {
|
||||
script.defer = true;
|
||||
}
|
||||
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
window.silktideCookieBannerManager.initCookieBanner = initCookieBanner;
|
||||
window.silktideCookieBannerManager.updateCookieBannerConfig = updateCookieBannerConfig;
|
||||
window.silktideCookieBannerManager.injectScript = injectScript;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCookieBanner, {once: true});
|
||||
} else {
|
||||
initCookieBanner();
|
||||
}
|
||||
})();
|
||||
43
resources/views/banned.blade.php
Normal file
43
resources/views/banned.blade.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Account Banned</title>
|
||||
<link rel="icon" type="image/png" href="{{ asset('images/logo.webp') }}">
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white font-sans antialiased">
|
||||
|
||||
<div class="min-h-screen flex flex-col justify-center items-center py-12 px-6 sm:px-8">
|
||||
<div class="text-center max-w-xl">
|
||||
<!-- Circular Cross Ban Icon -->
|
||||
<svg class="w-20 h-20 text-red-500 dark:text-red-500 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 6l12 12M18 6L6 18"/>
|
||||
</svg>
|
||||
|
||||
<h1 class="mt-6 text-lg font-bold text-red-400 dark:text-red-400">Account Access Restricted</h1>
|
||||
<p class="mt-4 text-md text-gray-700 dark:text-gray-300">
|
||||
We regret to inform you that your account has been temporarily banned due to potential abuse detected by our system.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<p class="text-md text-gray-600 dark:text-gray-300">
|
||||
If you believe this is a mistake, please reach out to us for assistance.
|
||||
</p>
|
||||
|
||||
<flux:button as="a" href="mailto:contact@zemail.me">
|
||||
Contact Support
|
||||
</flux:button>
|
||||
|
||||
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Or email us at <a href="mailto:contact@zemail.me" class="underline text-blue-500">contact@zemail.me</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -40,7 +40,7 @@
|
||||
//['label' => '10 Minute Mail', 'route' => 'dashboard.10minute'],
|
||||
['label' => 'Bulk Email Generator', 'route' => 'dashboard.bulk'],
|
||||
['label' => 'Bulk Gmail Generator', 'route' => 'dashboard.bulkGmail'],
|
||||
//['label' => 'Compose Email', 'route' => 'dashboard.compose'],
|
||||
['label' => 'Support Ticket', 'route' => 'dashboard.support'],
|
||||
];
|
||||
|
||||
$currentRoute = Route::currentRouteName();
|
||||
|
||||
26
resources/views/emails/ticket/response.blade.php
Normal file
26
resources/views/emails/ticket/response.blade.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<x-mail::message>
|
||||
# Support Ticket Response : #{{ $ticket->ticket_id }}
|
||||
|
||||
**Subject:** {{ $ticket->subject }}
|
||||
|
||||
**Message:**
|
||||
{{ $ticket->message }}
|
||||
|
||||
|
||||
@foreach ($responses as $response)
|
||||
**Response from {{ $response->user_id === 1 ? 'Support Team' : $response->user->name }} ({{ $response->created_at->diffForHumans() }}):**
|
||||
|
||||
> {{ $response->response }}
|
||||
|
||||
@if (!$loop->last)
|
||||
---
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<x-mail::button :url="route('dashboard.support')">
|
||||
View Ticket
|
||||
</x-mail::button>
|
||||
|
||||
Thanks,<br>
|
||||
{{ config('app.name') }}
|
||||
</x-mail::message>
|
||||
@@ -0,0 +1,10 @@
|
||||
<x-filament::page>
|
||||
<form wire:submit.prevent="generate" class="space-y-6">
|
||||
{{ $this->form }}
|
||||
|
||||
<x-filament::button type="submit">
|
||||
Generate Activation Keys
|
||||
</x-filament::button>
|
||||
</form>
|
||||
{{ $this->table }}
|
||||
</x-filament::page>
|
||||
@@ -31,7 +31,10 @@
|
||||
@if($subscription['ends_at'] ?? "") end at<span class="app-primary"> {{ \Carbon\Carbon::make($subscription['ends_at'])->toFormattedDayDateString() ?? "" }}.</span>
|
||||
@else auto-renew as per the plan you chose.
|
||||
@endif
|
||||
To manage you subscription <a href="{{ route('billing') }}" target="_blank" class="text-blue-500">Click here</a></p>
|
||||
@if($showStripeBilling)
|
||||
To manage you subscription <a href="{{ route('billing') }}" target="_blank" class="text-blue-500">Click here</a>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
@else
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 ">
|
||||
{{-- <script src="https://shoppy.gg/api/embed.js"></script>--}}
|
||||
<div class="w-full mb-8 items-center flex justify-center">
|
||||
<h1 class="text-center text-3xl text-gray-900 dark:text-gray-200">Purchase Subscription</h1>
|
||||
</div>
|
||||
@@ -35,12 +36,81 @@
|
||||
@endif
|
||||
</ul>
|
||||
|
||||
@if($plan->accept_stripe && $plan->pricing_id !== null)
|
||||
<flux:button variant="primary" class="w-full mt-6 cursor-pointer" wire:click="choosePlan('{{ $plan->pricing_id }}')">
|
||||
Choose Plan
|
||||
Pay with card
|
||||
</flux:button>
|
||||
@endif
|
||||
@if($plan->accept_shoppy && $plan->shoppy_product_id !== null)
|
||||
<flux:button variant="filled" class="w-full mt-2 cursor-pointer" data-shoppy-product="{{ $plan->shoppy_product_id }}">
|
||||
Pay with crypto
|
||||
</flux:button>
|
||||
@endif
|
||||
@if($plan->accept_oxapay && $plan->oxapay_link !== null)
|
||||
<flux:button
|
||||
variant="filled"
|
||||
class="w-full mt-2 cursor-pointer"
|
||||
tag="a"
|
||||
href="{{ $plan->oxapay_link }}"
|
||||
target="_blank"
|
||||
>
|
||||
Pay with crypto
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="w-full mt-8">
|
||||
<flux:separator text="or" />
|
||||
<div class="w-full mt-4 mb-8 items-center flex justify-center">
|
||||
<h1 class="text-center text-3xl text-gray-900 dark:text-gray-200">Have an Activation Key?</h1>
|
||||
</div>
|
||||
<div class="flex rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-zinc-800">
|
||||
<input
|
||||
type="text"
|
||||
wire:model="activation_key"
|
||||
class="w-full px-4 py-2 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-500 focus:outline-none"
|
||||
placeholder="Enter your activation key"
|
||||
/>
|
||||
<button
|
||||
wire:click="activateKey"
|
||||
class="cursor-pointer px-5 text-white transition-colors dark:text-white bg-[#4361EE] dark:bg-[#4361EE] disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
:disabled="wire:loading"
|
||||
>
|
||||
<!-- Show Loader when loading -->
|
||||
<span wire:loading.remove>Activate</span>
|
||||
<span wire:loading class="flex justify-center items-center px-4">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_7NYg{animation:spinner_0KQs 1.2s cubic-bezier(0.52,.6,.25,.99) infinite}@keyframes spinner_0KQs{0%{r:0;opacity:1}100%{r:11px;opacity:0}}</style><circle class="spinner_7NYg" cx="12" cy="12" r="0" fill="white"/></svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Redeem your activation key, purchased with Pay with Crypto option.
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
@error('activation_key')
|
||||
<div class="mt-4 app-primary">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@enderror
|
||||
<!-- Success/Error Message -->
|
||||
@if (session()->has('success'))
|
||||
<div class="mt-4" style="color: #00AB55">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session()->has('error'))
|
||||
<div class="mt-4 app-primary">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
203
resources/views/livewire/dashboard/support.blade.php
Normal file
203
resources/views/livewire/dashboard/support.blade.php
Normal file
@@ -0,0 +1,203 @@
|
||||
@section('title'){{ __('Support Ticket') }}@endsection
|
||||
<span>
|
||||
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-2">
|
||||
|
||||
<article class="flex items-center gap-4 rounded-lg border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]">
|
||||
<span class="rounded-full bg-[#F04743]/20 p-3 text-[#F04743] dark:bg-[#F04743]/20 dark:text-[#F04743]">
|
||||
<flux:icon.clock />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-2xl font-medium text-gray-900 dark:text-white">{{ $open }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Opened Tickets</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flex items-center gap-4 rounded-lg border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-white/[0.03]">
|
||||
<span class="rounded-full bg-[#F04743]/20 p-3 text-[#F04743] dark:bg-[#F04743]/20 dark:text-[#F04743]">
|
||||
<flux:icon.circle-x />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-2xl font-medium text-gray-900 dark:text-white">{{ $closed }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Closed Tickets</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div x-data="{ show: false, id: 0 }">
|
||||
<div class="flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700 dark:bg-white/[0.03]">
|
||||
<div x-show="!show" class="list">
|
||||
<div class="flex justify-between items-center p-2 dark:bg-zinc-900 bg-gray-300">
|
||||
<h3 class="text-md font-semibold text-gray-900 dark:text-white pl-1">Tickets</h3>
|
||||
<flux:modal.trigger name="create-ticket">
|
||||
<flux:button class="cursor-pointer">Create Ticket</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="show">
|
||||
<div class="flex justify-between items-center p-2 dark:bg-zinc-900 bg-gray-300">
|
||||
<nav class="flex items-center cursor-pointer py-3" x-on:click="show = false">
|
||||
<flux:icon.chevron-left variant="mini"/>
|
||||
<flux:text>{{ __('Get back to Tickets') }}</flux:text>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@if(count($tickets) > 0)
|
||||
<div class="ticketbox">
|
||||
<div x-show="!show" class="list">
|
||||
<!-- Tickets Table -->
|
||||
<div class="overflow-x-auto shadow-md sm:rounded-lg">
|
||||
<table class="min-w-full table-auto text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-200 dark:bg-zinc-800 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">ID</th>
|
||||
<th scope="col" class="px-6 py-3">Subject</th>
|
||||
<th scope="col" class="px-6 py-3">Status</th>
|
||||
<th scope="col" class="px-6 py-3">Created Date</th>
|
||||
<th scope="col" class="px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@foreach(collect($tickets)->reverse() as $index => $ticket)
|
||||
<tr class="border-b dark:border-gray-700 border-gray-200">
|
||||
<td class="px-6 py-4">#{{ $ticket->ticket_id }}</td>
|
||||
<td class="px-6 py-4 truncate">{{ $ticket->subject }}</td>
|
||||
<td class="px-6 py-4">
|
||||
@if($ticket->status == "open")
|
||||
<flux:badge size="sm" color="green">Open</flux:badge>
|
||||
@elseif($ticket->status == "pending")
|
||||
<flux:badge size="sm" color="yellow">In-Progress</flux:badge>
|
||||
@else
|
||||
<flux:badge size="sm" color="red">Closed</flux:badge>
|
||||
@endif
|
||||
|
||||
</td>
|
||||
<td class="px-6 py-4">{{ $ticket->created_at->diffForHumans() }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<button class="cursor-pointer px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none"
|
||||
x-on:click="show = true; id = {{ $index }};" data-id="{{ $index }}"
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="show" class="message">
|
||||
@foreach(collect($tickets)->reverse() as $index => $ticket)
|
||||
<div x-show="id === {{ $index }}" id="ticket-{{ $index }}">
|
||||
<div class="overflow-x-auto shadow-md sm:rounded-lg p-4">
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- Left Column (Messages & Reply Form) -->
|
||||
<div class="w-full lg:w-2/3 space-y-4">
|
||||
<!-- Customer Message -->
|
||||
<div class="bg-white dark:bg-zinc-800 border-l-4 shadow rounded p-4">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="font-semibold text-gray-800 dark:text-gray-200">{{ auth()->user()->name }}</span>
|
||||
<span class="text-sm text-gray-500">{{ $ticket->created_at->format('F d, Y • h:i A') }}</span>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300">Subject: {{ $ticket->subject }}<br/>Message: {{ $ticket->message }}</p>
|
||||
</div>
|
||||
|
||||
@if(count($ticket->responses) > 0)
|
||||
@foreach($ticket->responses as $response)
|
||||
<div class="bg-white dark:bg-zinc-800 border-l-4 shadow rounded p-4">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="font-semibold text-gray-800 dark:text-gray-200">{{ auth()->user()->id === $response->user_id ? auth()->user()->name : 'Support Team' }}</span>
|
||||
<span class="text-sm text-gray-500">{{ $response->created_at->format('F d, Y • h:i A') }}</span>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300">{{ $response->response }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@if($ticket->status !== "closed")
|
||||
<!-- Reply Form -->
|
||||
<div class="bg-white dark:bg-zinc-800 p-4 shadow rounded">
|
||||
<textarea wire:model="response" class="w-full p-3 rounded border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-gray-800 dark:text-white focus:outline-none focus:ring focus:ring-blue-500" rows="4" placeholder="Type your response..."></textarea>
|
||||
@error('response')
|
||||
<div class="my-2 app-primary">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@enderror
|
||||
<!-- Success/Error Message -->
|
||||
@if (session()->has('success'))
|
||||
<div class="my-2" style="color: #00AB55">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if (session()->has('error'))
|
||||
<div class="my-2 app-primary">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
<flux:button variant="outline" wire:click="reply('{{ $ticket->id }}')">Send Reply</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Column (Ticket Details) -->
|
||||
<div class="w-full lg:w-1/3">
|
||||
<div class="bg-white dark:bg-zinc-800 shadow rounded-lg p-4 space-y-2">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">Ticket Details</h2>
|
||||
<div class="text-gray-700 dark:text-gray-300">
|
||||
<p><strong>Ticket ID:</strong> #{{ $ticket->ticket_id }}</p>
|
||||
<p><strong>Created At:</strong> {{ $ticket->created_at->format('F d, Y • h:i A') }}</p>
|
||||
<p><strong>Last Response:</strong> {{ $ticket->last_response_at ? $ticket->last_response_at->format('F d, Y • h:i A') : 'No responses yet' }}</p>
|
||||
<span class="flex flex-row gap-2"><p><strong>Status:</strong></p>
|
||||
@if($ticket->status == "open")
|
||||
<flux:badge size="sm" color="green">Open</flux:badge>
|
||||
@elseif($ticket->status == "pending")
|
||||
<flux:badge size="sm" color="yellow">In-Progress</flux:badge>
|
||||
@else
|
||||
<flux:badge size="sm" color="red">Closed</flux:badge>
|
||||
@endif
|
||||
</span>
|
||||
@if($ticket->status !== "closed")
|
||||
<flux:button class="mt-2 cursor-pointer inbox-btn" wire:click="close('{{ $ticket->id }}')">Close Ticket</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex justify-center items-center" style="min-height: 400px;">
|
||||
<p class="text-gray-500 dark:text-gray-400">No Tickets</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<flux:modal name="create-ticket" class="md:w-96">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Create ticket</flux:heading>
|
||||
@if (session()->has('success'))
|
||||
<flux:text class="mt-2" style="color: #00AB55">{{ session('success') }}</flux:text>
|
||||
@endif
|
||||
@if (session()->has('error'))
|
||||
<flux:text class="mt-2 app-primary" >{{ session('error') }}</flux:text>
|
||||
@endif
|
||||
</div>
|
||||
<flux:input label="Subject" placeholder="Enter subject" wire:model="subject"/>
|
||||
<flux:textarea
|
||||
label="Message"
|
||||
placeholder="Enter the issue you are facing or just give us feedback what we can improve"
|
||||
wire:model="message"
|
||||
/>
|
||||
<div class="flex">
|
||||
<flux:spacer />
|
||||
<flux:button class="cursor-pointer" type="submit" variant="primary" wire:click="store">Create</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</span>
|
||||
8
routes/api.php
Normal file
8
routes/api.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/user', function (Request $request) {
|
||||
return $request->user();
|
||||
})->middleware('auth:sanctum');
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Email;
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
@@ -34,5 +35,8 @@ Schedule::call(function () {
|
||||
|
||||
Artisan::command('cleanMail', function (){
|
||||
$this->comment(Email::cleanMailbox());
|
||||
|
||||
});
|
||||
|
||||
Artisan::command('closeTicket', function (){
|
||||
$this->comment(Ticket::autoClose());
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AppController;
|
||||
use App\Http\Controllers\WebhookController;
|
||||
use App\Http\Middleware\CheckPageSlug;
|
||||
use App\Http\Middleware\CheckUserBanned;
|
||||
use App\Livewire\AddOn;
|
||||
use App\Livewire\Blog;
|
||||
use App\Livewire\Dashboard\Bulk;
|
||||
use App\Livewire\Dashboard\BulkGmail;
|
||||
use App\Livewire\Dashboard\Dashboard;
|
||||
use App\Livewire\Dashboard\Mailbox\Inbox;
|
||||
use App\Livewire\Dashboard\Support;
|
||||
use App\Livewire\Frontend\Mailbox;
|
||||
use App\Livewire\Home;
|
||||
use App\Livewire\ListBlog;
|
||||
@@ -53,13 +56,14 @@ Route::get('gmailnator', AddOn::class)->name('gmailnator');
|
||||
Route::get('emailnator', AddOn::class)->name('emailnator');
|
||||
Route::get('temp-gmail', AddOn::class)->name('temp-gmail');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::middleware(['auth', 'verified', CheckUserBanned::class])->group(function () {
|
||||
Route::get('dashboard', Dashboard::class)->name('dashboard');
|
||||
Route::get('dashboard/generate-premium-email', Inbox::class)->name('dashboard.premium');
|
||||
Route::get('dashboard/generate-10minute-email', Dashboard::class)->name('dashboard.10minute');
|
||||
Route::get('dashboard/bulk-email-generator', Bulk::class)->name('dashboard.bulk');
|
||||
Route::get('dashboard/bulk-gmail-generator', BulkGmail::class)->name('dashboard.bulkGmail');
|
||||
Route::get('dashboard/compose-email', Dashboard::class)->name('dashboard.compose');
|
||||
Route::get('dashboard/support', Support::class)->name('dashboard.support');
|
||||
|
||||
// Checkout Routes
|
||||
Route::get('checkout/{plan}', function ($pricing_id) {
|
||||
@@ -74,6 +78,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
->newSubscription('default', $pricing_id)
|
||||
->allowPromotionCodes()
|
||||
->checkout([
|
||||
'billing_address_collection' => 'required',
|
||||
'success_url' => route('checkout.success'),
|
||||
'cancel_url' => route('checkout.cancel'),
|
||||
]);
|
||||
@@ -108,7 +113,28 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
return response()->json([
|
||||
'message' => trim($output),
|
||||
]);
|
||||
});
|
||||
})->name('storageLink');
|
||||
|
||||
Route::get('0xdash/scache', function (Request $request) {
|
||||
$validUser = 'admin';
|
||||
$validPass = 'admin@9608'; // 🔐 Change this to something secure
|
||||
|
||||
if (!isset($_SERVER['PHP_AUTH_USER']) ||
|
||||
$_SERVER['PHP_AUTH_USER'] !== $validUser ||
|
||||
$_SERVER['PHP_AUTH_PW'] !== $validPass) {
|
||||
|
||||
header('WWW-Authenticate: Basic realm="Restricted Area"');
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
echo 'Unauthorized';
|
||||
exit;
|
||||
}
|
||||
Artisan::call('cache:clear');
|
||||
$output = Artisan::output();
|
||||
|
||||
return response()->json([
|
||||
'message' => trim($output),
|
||||
]);
|
||||
})->name('cacheClear');
|
||||
|
||||
});
|
||||
|
||||
@@ -120,6 +146,8 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::get('settings/appearance', Appearance::class)->name('settings.appearance');
|
||||
});
|
||||
|
||||
Route::post('/webhook/oxapay', [WebhookController::class, 'oxapay'])->name('webhook.oxapay');
|
||||
|
||||
require __DIR__.'/auth.php';
|
||||
|
||||
Route::get('{slug}', Page::class)->where('slug', '.*')->name('page')->middleware(CheckPageSlug::class);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user