Add recipient email and milestone notifications on transfer create.
Deploy Ladill Transfer / deploy (push) Successful in 44s
Deploy Ladill Transfer / deploy (push) Successful in 44s
Recipients can be emailed the download link immediately and at optional reminders (7/3/1 days before unavailable, or when grace period starts). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Transfer\TransferBillingService;
|
||||
use App\Services\Transfer\TransferRecipientMailService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ProcessTransferBillingCommand extends Command
|
||||
@@ -11,15 +12,17 @@ class ProcessTransferBillingCommand extends Command
|
||||
|
||||
protected $description = 'Renew monthly transfer storage billing and delete transfers past the grace period';
|
||||
|
||||
public function handle(TransferBillingService $billing): int
|
||||
public function handle(TransferBillingService $billing, TransferRecipientMailService $recipients): int
|
||||
{
|
||||
$result = $billing->processDueRenewals();
|
||||
$recipientEmails = $recipients->processDueMilestones();
|
||||
|
||||
$this->info(sprintf(
|
||||
'Transfer billing: %d renewed, %d entered grace, %d deleted.',
|
||||
'Transfer billing: %d renewed, %d entered grace, %d deleted, %d recipient reminders sent.',
|
||||
$result['renewed'],
|
||||
$result['graced'],
|
||||
$result['deleted'],
|
||||
$recipientEmails,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Transfer;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
use App\Services\Qr\QrPdfExporter;
|
||||
use App\Services\Transfer\TransferRecipientMailService;
|
||||
use App\Services\Transfer\TransferService;
|
||||
use App\Services\Upload\ChunkedUploadService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -41,10 +42,14 @@ class TransferController extends Controller
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$mailService = app(TransferRecipientMailService::class);
|
||||
|
||||
return view('transfer.transfers.create', [
|
||||
'maxFiles' => (int) config('transfer.max_files_per_transfer', 20),
|
||||
'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30),
|
||||
'gracePeriodDays' => (int) config('transfer.grace_period_days', 15),
|
||||
'emailMilestones' => $mailService->milestoneOptions(),
|
||||
'defaultEmailMilestones' => (array) config('transfer.default_recipient_email_milestones', ['created']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -58,9 +63,14 @@ class TransferController extends Controller
|
||||
$fileRules[] = 'max:'.(int) ceil($maxBytes / 1024);
|
||||
}
|
||||
|
||||
$allowedMilestones = implode(',', app(TransferRecipientMailService::class)->allowedMilestones());
|
||||
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:120',
|
||||
'message' => 'nullable|string|max:2000',
|
||||
'recipient_email' => 'nullable|email|max:255|required_with:email_milestones',
|
||||
'email_milestones' => 'nullable|array',
|
||||
'email_milestones.*' => 'in:'.$allowedMilestones,
|
||||
'password' => 'nullable|string|min:4|max:64',
|
||||
'files' => 'nullable|array',
|
||||
'files.*' => $fileRules,
|
||||
@@ -94,9 +104,14 @@ class TransferController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$success = 'Transfer created. Share the link or QR code with recipients.';
|
||||
if ($transfer->recipient_email && $transfer->wantsRecipientMilestone('created') && $transfer->recipientMilestoneSent('created')) {
|
||||
$success .= ' A download link was emailed to '.$transfer->recipient_email.'.';
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('transfer.transfers.show', $transfer)
|
||||
->with('success', 'Transfer created. Share the link or QR code with recipients.');
|
||||
->with('success', $success);
|
||||
}
|
||||
|
||||
public function show(Transfer $transfer): View
|
||||
|
||||
@@ -23,6 +23,9 @@ class Transfer extends Model
|
||||
'qr_code_id',
|
||||
'title',
|
||||
'message',
|
||||
'recipient_email',
|
||||
'recipient_email_milestones',
|
||||
'recipient_milestones_sent',
|
||||
'password_hash',
|
||||
'retention_days',
|
||||
'expires_at',
|
||||
@@ -40,6 +43,8 @@ class Transfer extends Model
|
||||
'paid_until' => 'datetime',
|
||||
'grace_ends_at' => 'datetime',
|
||||
'last_billed_at' => 'datetime',
|
||||
'recipient_email_milestones' => 'array',
|
||||
'recipient_milestones_sent' => 'array',
|
||||
'downloads_total' => 'integer',
|
||||
'storage_bytes' => 'integer',
|
||||
];
|
||||
@@ -149,4 +154,24 @@ class Transfer extends Model
|
||||
->orWhere('grace_ends_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function wantsRecipientMilestone(string $milestone): bool
|
||||
{
|
||||
return in_array($milestone, (array) ($this->recipient_email_milestones ?? []), true);
|
||||
}
|
||||
|
||||
public function recipientMilestoneSent(string $milestone): bool
|
||||
{
|
||||
return in_array($milestone, (array) ($this->recipient_milestones_sent ?? []), true);
|
||||
}
|
||||
|
||||
public function markRecipientMilestoneSent(string $milestone): void
|
||||
{
|
||||
$sent = (array) ($this->recipient_milestones_sent ?? []);
|
||||
$sent[] = $milestone;
|
||||
|
||||
$this->forceFill([
|
||||
'recipient_milestones_sent' => array_values(array_unique($sent)),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Transfer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TransferRecipientNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Transfer $transfer,
|
||||
private readonly string $milestone,
|
||||
) {}
|
||||
|
||||
public function via(mixed $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(mixed $notifiable): MailMessage
|
||||
{
|
||||
$transfer = $this->transfer->loadMissing(['files', 'qrCode', 'user']);
|
||||
$publicUrl = $transfer->qrCode?->publicUrl() ?? '';
|
||||
|
||||
return (new MailMessage())
|
||||
->subject($this->subjectLine())
|
||||
->view('mail.notifications.transfer-recipient', [
|
||||
'transfer' => $transfer,
|
||||
'publicUrl' => $publicUrl,
|
||||
'milestone' => $this->milestone,
|
||||
'senderName' => $transfer->user?->name,
|
||||
'daysRemaining' => $this->daysRemaining(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function subjectLine(): string
|
||||
{
|
||||
return match ($this->milestone) {
|
||||
'created' => $this->transfer->user?->name
|
||||
? "{$this->transfer->user->name} shared files with you"
|
||||
: "Files shared with you: {$this->transfer->title}",
|
||||
'grace' => "Download soon — {$this->transfer->title} needs payment",
|
||||
'1' => "Last day to download: {$this->transfer->title}",
|
||||
'3' => "3 days left to download: {$this->transfer->title}",
|
||||
'7' => "Download reminder: {$this->transfer->title}",
|
||||
default => "Download reminder: {$this->transfer->title}",
|
||||
};
|
||||
}
|
||||
|
||||
private function daysRemaining(): ?int
|
||||
{
|
||||
if (! is_numeric($this->milestone)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $this->milestone;
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ class AfiaService
|
||||
|
||||
What Ladill Transfer does and where things live:
|
||||
- Overview (Dashboard): active transfers, storage used, downloads in the last 30 days, transfers expiring soon, wallet balance.
|
||||
- Transfers: list all file shares; create a new transfer (upload one or more files, title, optional message, retention period, optional password).
|
||||
- Transfers: list all file shares; create a new transfer (upload files, optional recipient email with milestone reminders, optional password).
|
||||
- After creating a transfer: users get a share link and downloadable QR code; recipients scan ladill.com/q/{code} or open the link.
|
||||
- Files: browse all stored files across transfers with sizes and download counts.
|
||||
- Analytics: download trends, top transfers, recent download activity.
|
||||
|
||||
@@ -102,6 +102,8 @@ class TransferBillingService
|
||||
'grace_ends_at' => $graceEndsAt,
|
||||
'expires_at' => $graceEndsAt,
|
||||
]);
|
||||
|
||||
app(TransferRecipientMailService::class)->notifyRecipient($transfer->fresh(), 'grace');
|
||||
}
|
||||
|
||||
public function processDueRenewals(): array
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Transfer;
|
||||
|
||||
use App\Models\Transfer;
|
||||
use App\Notifications\TransferRecipientNotification;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
/** Sends milestone emails to transfer recipients (share link + reminders). */
|
||||
class TransferRecipientMailService
|
||||
{
|
||||
/** @return list<string> */
|
||||
public function allowedMilestones(): array
|
||||
{
|
||||
return array_keys((array) config('transfer.recipient_email_milestones', []));
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function milestoneOptions(): array
|
||||
{
|
||||
return (array) config('transfer.recipient_email_milestones', []);
|
||||
}
|
||||
|
||||
/** @param list<string> $milestones */
|
||||
public function normalizeMilestones(array $milestones): array
|
||||
{
|
||||
$allowed = $this->allowedMilestones();
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
$milestones,
|
||||
static fn ($milestone) => in_array((string) $milestone, $allowed, true),
|
||||
)));
|
||||
}
|
||||
|
||||
public function notifyRecipient(Transfer $transfer, string $milestone): bool
|
||||
{
|
||||
$email = trim((string) $transfer->recipient_email);
|
||||
if ($email === '' || ! $transfer->wantsRecipientMilestone($milestone)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($transfer->recipientMilestoneSent($milestone)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Notification::route('mail', $email)->notify(
|
||||
new TransferRecipientNotification($transfer->loadMissing(['files', 'qrCode', 'user']), $milestone),
|
||||
);
|
||||
|
||||
$transfer->markRecipientMilestoneSent($milestone);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function processDueMilestones(): int
|
||||
{
|
||||
$sent = 0;
|
||||
|
||||
Transfer::query()
|
||||
->accessible()
|
||||
->whereNotNull('recipient_email')
|
||||
->whereNotNull('recipient_email_milestones')
|
||||
->with(['files', 'qrCode', 'user'])
|
||||
->chunkById(100, function ($transfers) use (&$sent) {
|
||||
foreach ($transfers as $transfer) {
|
||||
foreach ($this->dueDayMilestones($transfer) as $milestone) {
|
||||
if ($this->notifyRecipient($transfer, $milestone)) {
|
||||
$sent++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $sent;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function dueDayMilestones(Transfer $transfer): array
|
||||
{
|
||||
if (! $transfer->paid_until instanceof CarbonInterface || $transfer->paid_until->isPast()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$daysRemaining = (int) now()->startOfDay()->diffInDays(
|
||||
$transfer->paid_until->copy()->startOfDay(),
|
||||
false,
|
||||
);
|
||||
|
||||
$due = [];
|
||||
foreach (['7', '3', '1'] as $milestone) {
|
||||
if ($daysRemaining === (int) $milestone && $transfer->wantsRecipientMilestone($milestone)) {
|
||||
$due[] = $milestone;
|
||||
}
|
||||
}
|
||||
|
||||
return $due;
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,24 @@ class TransferService
|
||||
$passwordHash = Hash::make((string) $data['password']);
|
||||
}
|
||||
|
||||
$recipientEmail = trim((string) ($data['recipient_email'] ?? ''));
|
||||
$recipientMilestones = app(TransferRecipientMailService::class)->normalizeMilestones(
|
||||
is_array($data['email_milestones'] ?? null) ? $data['email_milestones'] : [],
|
||||
);
|
||||
|
||||
if ($recipientEmail !== '' && $recipientMilestones === []) {
|
||||
$recipientMilestones = app(TransferRecipientMailService::class)->normalizeMilestones(
|
||||
(array) config('transfer.default_recipient_email_milestones', ['created']),
|
||||
);
|
||||
}
|
||||
|
||||
$transfer = Transfer::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => trim((string) $data['title']),
|
||||
'message' => isset($data['message']) ? trim((string) $data['message']) : null,
|
||||
'recipient_email' => $recipientEmail !== '' ? $recipientEmail : null,
|
||||
'recipient_email_milestones' => $recipientEmail !== '' ? $recipientMilestones : null,
|
||||
'recipient_milestones_sent' => [],
|
||||
'password_hash' => $passwordHash,
|
||||
'retention_days' => $billingPeriodDays,
|
||||
'status' => Transfer::STATUS_ACTIVE,
|
||||
@@ -83,7 +97,10 @@ class TransferService
|
||||
$transfer = $transfer->fresh(['files', 'qrCode']);
|
||||
app(TransferBillingService::class)->chargeInitialPeriod($transfer, $user);
|
||||
|
||||
return $transfer->fresh(['files', 'qrCode']);
|
||||
$transfer = $transfer->fresh(['files', 'qrCode']);
|
||||
app(TransferRecipientMailService::class)->notifyRecipient($transfer, 'created');
|
||||
|
||||
return $transfer;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@ return [
|
||||
// Days to keep files after a failed renewal before automatic deletion.
|
||||
'grace_period_days' => (int) env('TRANSFER_GRACE_PERIOD_DAYS', 15),
|
||||
|
||||
// Recipient email milestones (checkboxes on the create transfer form).
|
||||
'recipient_email_milestones' => [
|
||||
'created' => 'Send download link immediately',
|
||||
'7' => '7 days before files become unavailable',
|
||||
'3' => '3 days before files become unavailable',
|
||||
'1' => '1 day before files become unavailable',
|
||||
'grace' => 'When files enter the payment grace period',
|
||||
],
|
||||
|
||||
'default_recipient_email_milestones' => ['created'],
|
||||
|
||||
// Legacy env keys (billing is monthly until cancelled by non-payment + grace).
|
||||
'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30),
|
||||
'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('transfers', function (Blueprint $table) {
|
||||
$table->string('recipient_email')->nullable()->after('message');
|
||||
$table->json('recipient_email_milestones')->nullable()->after('recipient_email');
|
||||
$table->json('recipient_milestones_sent')->nullable()->after('recipient_email_milestones');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('transfers', function (Blueprint $table) {
|
||||
$table->dropColumn(['recipient_email', 'recipient_email_milestones', 'recipient_milestones_sent']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,402 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>{{ $subject ?? 'Ladill Notification' }}</title>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
background-color: #f8fafc;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.email-wrapper {
|
||||
width: 100%;
|
||||
background-color: #f8fafc;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.email-header {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
padding: 32px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-logo {
|
||||
max-height: 36px;
|
||||
width: auto;
|
||||
}
|
||||
.email-icon-header {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
padding: 48px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.email-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.email-header-title {
|
||||
color: #ffffff;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.email-header-subtitle {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
.email-body {
|
||||
padding: 40px;
|
||||
}
|
||||
.email-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.email-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
.email-text-sm {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.email-button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0;
|
||||
box-shadow: 0 4px 14px 0 rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.email-button-secondary {
|
||||
display: inline-block;
|
||||
background-color: #f1f5f9;
|
||||
color: #0f172a !important;
|
||||
text-decoration: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 8px 4px;
|
||||
}
|
||||
.email-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #e2e8f0, transparent);
|
||||
margin: 32px 0;
|
||||
}
|
||||
.email-details {
|
||||
background-color: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.email-details-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
.email-details-row {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.email-details-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.email-details-row:first-of-type {
|
||||
padding-top: 0;
|
||||
}
|
||||
.email-details-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.email-details-value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.email-card {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.email-card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.email-card-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #0f172a;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-footer-text {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.email-footer-link {
|
||||
color: #a5b4fc;
|
||||
text-decoration: none;
|
||||
}
|
||||
.email-footer-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.email-footer-links {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.email-footer-links a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
.email-footer-links a:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
.highlight-box {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
border-radius: 12px;
|
||||
padding: 28px;
|
||||
margin: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.highlight-box-text {
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.highlight-box-subtext {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 14px;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
.highlight-box-success {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
}
|
||||
.highlight-box-warning {
|
||||
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
|
||||
}
|
||||
.highlight-box-info {
|
||||
background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%);
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.status-success {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.status-warning {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.status-info {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.status-error {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.checklist-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.checklist-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #dcfce7;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.checklist-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #166534;
|
||||
}
|
||||
.checklist-content {
|
||||
flex: 1;
|
||||
}
|
||||
.checklist-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
.checklist-desc {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.code-block {
|
||||
background-color: #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin: 16px 0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 14px;
|
||||
color: #e2e8f0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.amount-large {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.amount-currency {
|
||||
font-size: 18px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
@media only screen and (max-width: 620px) {
|
||||
.email-wrapper {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.email-container {
|
||||
border-radius: 12px;
|
||||
}
|
||||
.email-header,
|
||||
.email-icon-header {
|
||||
padding: 24px;
|
||||
}
|
||||
.email-body,
|
||||
.email-footer {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.email-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.email-header-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.amount-large {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<div class="email-container">
|
||||
{{-- Header with Logo --}}
|
||||
<div class="email-header">
|
||||
<img src="{{ config('app.url') }}/images/logo/ladill-logo-white.png" alt="Ladill" class="email-logo">
|
||||
</div>
|
||||
|
||||
@hasSection('icon-header')
|
||||
@yield('icon-header')
|
||||
@endif
|
||||
|
||||
{{-- Email Body --}}
|
||||
<div class="email-body">
|
||||
@yield('content')
|
||||
</div>
|
||||
|
||||
{{-- Footer --}}
|
||||
<div class="email-footer">
|
||||
<p class="email-footer-text">
|
||||
You're receiving this email because you have an account with Ladill.
|
||||
</p>
|
||||
<div class="email-footer-links">
|
||||
<a href="{{ config('app.url') }}/dashboard">Dashboard</a>
|
||||
<a href="{{ config('app.url') }}/support">Support</a>
|
||||
<a href="{{ config('app.url') }}">ladill.com</a>
|
||||
</div>
|
||||
<p class="email-footer-text" style="margin-top: 20px; color: #64748b;">
|
||||
© 2026 Ladill Technologies. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
@extends('mail.notifications.layout')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$fileCount = $transfer->files->count();
|
||||
$availableUntil = $transfer->isInGracePeriod()
|
||||
? $transfer->grace_ends_at
|
||||
: $transfer->paid_until;
|
||||
@endphp
|
||||
|
||||
<div class="highlight-box highlight-box-success">
|
||||
<p class="highlight-box-text">📁 File transfer</p>
|
||||
<p class="highlight-box-subtext">{{ $transfer->title }}</p>
|
||||
</div>
|
||||
|
||||
@if($milestone === 'created')
|
||||
<h1 class="email-title">You have files to download</h1>
|
||||
<p class="email-text">
|
||||
@if($senderName)
|
||||
<strong>{{ $senderName }}</strong> shared {{ $fileCount === 1 ? 'a file' : $fileCount.' files' }} with you via Ladill Transfer.
|
||||
@else
|
||||
Someone shared {{ $fileCount === 1 ? 'a file' : $fileCount.' files' }} with you via Ladill Transfer.
|
||||
@endif
|
||||
</p>
|
||||
@elseif($milestone === 'grace')
|
||||
<h1 class="email-title">Download before these files are removed</h1>
|
||||
<p class="email-text">
|
||||
The transfer <strong>{{ $transfer->title }}</strong> is in a grace period.
|
||||
@if($availableUntil)
|
||||
Files will be deleted on <strong>{{ $availableUntil->format('F j, Y') }}</strong> unless payment is received.
|
||||
@endif
|
||||
</p>
|
||||
@else
|
||||
<h1 class="email-title">Reminder: download your files</h1>
|
||||
<p class="email-text">
|
||||
<strong>{{ $transfer->title }}</strong>
|
||||
@if($daysRemaining === 1)
|
||||
will become unavailable tomorrow.
|
||||
@else
|
||||
will become unavailable in {{ $daysRemaining }} days.
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if($transfer->message)
|
||||
<p class="email-text" style="background:#f8fafc;border-radius:10px;padding:14px 16px;color:#475569;">
|
||||
{{ $transfer->message }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if($transfer->isPasswordProtected())
|
||||
<p class="email-text" style="font-size:13px;color:#b45309;">
|
||||
This transfer is password protected. The sender will need to share the password with you separately.
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{ $publicUrl }}" class="email-button" style="display:inline-block;background:#4f46e5;color:#ffffff;text-decoration:none;padding:12px 28px;border-radius:10px;font-size:14px;font-weight:600;">
|
||||
Download files
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="email-text" style="font-size:13px;color:#6b7280;">
|
||||
Or open this link: <a href="{{ $publicUrl }}" style="color:#4f46e5;">{{ $publicUrl }}</a>
|
||||
</p>
|
||||
|
||||
@if($availableUntil && $milestone !== 'grace')
|
||||
<p class="email-text" style="font-size:13px;color:#6b7280;">
|
||||
Available until {{ $availableUntil->format('F j, Y') }}.
|
||||
</p>
|
||||
@endif
|
||||
@endsection
|
||||
@@ -51,6 +51,27 @@
|
||||
@error('files.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="recipient_email" class="block text-sm font-medium text-slate-700">Recipient email <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<input type="email" name="recipient_email" id="recipient_email" value="{{ old('recipient_email') }}" maxlength="255"
|
||||
placeholder="friend@example.com"
|
||||
class="mt-1 block w-full rounded-xl border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@error('recipient_email')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
<p class="mt-2 text-xs font-medium text-slate-600">Email milestones</p>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach($emailMilestones as $id => $label)
|
||||
<label class="flex items-start gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="email_milestones[]" value="{{ $id }}"
|
||||
@checked(in_array($id, old('email_milestones', $defaultEmailMilestones), true))
|
||||
class="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<span>{{ $label }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('email_milestones')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
@error('email_milestones.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600">
|
||||
<p class="font-medium text-slate-900">Monthly storage billing</p>
|
||||
<p class="mt-1">GHS {{ number_format($pricePerGb, 2) }} per GB per month, debited from your wallet. Files stay available while payments succeed. If a monthly renewal fails, files are kept for {{ $gracePeriodDays }} days before deletion.</p>
|
||||
|
||||
@@ -35,6 +35,16 @@
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
@if($transfer->recipient_email)
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
Recipient: <span class="font-medium text-slate-900">{{ $transfer->recipient_email }}</span>
|
||||
@if(is_array($transfer->recipient_email_milestones) && $transfer->recipient_email_milestones !== [])
|
||||
<span class="text-slate-400">·</span>
|
||||
Notified on {{ implode(', ', array_map(fn ($m) => $m === 'created' ? 'share' : $m, $transfer->recipient_email_milestones)) }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="font-semibold text-slate-900">Share link</h2>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Transfer;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TransferRecipientNotification;
|
||||
use App\Services\Transfer\TransferRecipientMailService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\FakesTransferBilling;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TransferRecipientMailTest extends TestCase
|
||||
{
|
||||
use FakesTransferBilling;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_create_transfer_emails_recipient_when_created_milestone_selected(): void
|
||||
{
|
||||
Notification::fake();
|
||||
Storage::fake('qr');
|
||||
$this->fakeTransferBillingApi();
|
||||
|
||||
$user = User::create([
|
||||
'public_id' => (string) Str::uuid(),
|
||||
'name' => 'Sender',
|
||||
'email' => 'sender+'.uniqid().'@example.com',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->post(route('transfer.transfers.store'), [
|
||||
'title' => 'Project files',
|
||||
'recipient_email' => 'recipient@example.com',
|
||||
'email_milestones' => ['created'],
|
||||
'files' => [UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf')],
|
||||
])->assertRedirect();
|
||||
|
||||
Notification::assertSentOnDemand(
|
||||
TransferRecipientNotification::class,
|
||||
fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'recipient@example.com',
|
||||
);
|
||||
}
|
||||
|
||||
public function test_recipient_day_milestone_is_sent_once(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::create([
|
||||
'public_id' => (string) Str::uuid(),
|
||||
'name' => 'Sender',
|
||||
'email' => 'sender+'.uniqid().'@example.com',
|
||||
]);
|
||||
|
||||
$transfer = Transfer::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Reminder test',
|
||||
'recipient_email' => 'recipient@example.com',
|
||||
'recipient_email_milestones' => ['7'],
|
||||
'recipient_milestones_sent' => [],
|
||||
'retention_days' => 30,
|
||||
'paid_until' => now()->addDays(7)->startOfDay(),
|
||||
'status' => Transfer::STATUS_ACTIVE,
|
||||
'storage_bytes' => 1024,
|
||||
]);
|
||||
|
||||
$service = app(TransferRecipientMailService::class);
|
||||
$this->assertSame(1, $service->processDueMilestones());
|
||||
$this->assertSame(0, $service->processDueMilestones());
|
||||
|
||||
$transfer->refresh();
|
||||
$this->assertContains('7', $transfer->recipient_milestones_sent ?? []);
|
||||
|
||||
Notification::assertSentOnDemand(TransferRecipientNotification::class, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user