Add white-labeled marketing portal for dispensary partners:
- Business branding settings table and model for white-labeling
- Portal middleware (EnsureMarketingPortalAccess) with contact_type check
- Portal route group at /portal/{business}/*
- DashboardController with stats and CannaiQ recommendations
- PromoController for viewing recommended/existing promos
- CampaignController with create/send/schedule/cancel
- ListController for managing contact lists
- Policies for MarketingCampaign and MarketingPromo
- White-labeled portal layout with custom branding CSS
- Marketing Portal link in Filament admin for buyer businesses
- MarketingPortalUserSeeder for development testing
- PORTAL_ACCESS.md documentation
250 lines
8.1 KiB
PHP
250 lines
8.1 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Portal;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Jobs\SendMarketingCampaignJob;
|
|
use App\Models\Branding\BusinessBrandingSetting;
|
|
use App\Models\Business;
|
|
use App\Models\Marketing\MarketingCampaign;
|
|
use App\Models\Marketing\MarketingList;
|
|
use App\Models\Marketing\MarketingPromo;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
|
|
class CampaignController extends Controller
|
|
{
|
|
public function index(Request $request, Business $business)
|
|
{
|
|
$branding = BusinessBrandingSetting::forBusiness($business);
|
|
|
|
$campaigns = MarketingCampaign::where('business_id', $business->id)
|
|
->with('list')
|
|
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
|
->when($request->channel, fn ($q, $channel) => $q->where('channel', $channel))
|
|
->latest()
|
|
->paginate(15);
|
|
|
|
$statuses = [
|
|
'draft' => 'Draft',
|
|
'scheduled' => 'Scheduled',
|
|
'sending' => 'Sending',
|
|
'sent' => 'Sent',
|
|
'completed' => 'Completed',
|
|
'cancelled' => 'Cancelled',
|
|
'failed' => 'Failed',
|
|
];
|
|
|
|
$channels = MarketingCampaign::CHANNELS;
|
|
|
|
return view('portal.campaigns.index', compact(
|
|
'business',
|
|
'branding',
|
|
'campaigns',
|
|
'statuses',
|
|
'channels'
|
|
));
|
|
}
|
|
|
|
public function create(Request $request, Business $business)
|
|
{
|
|
$branding = BusinessBrandingSetting::forBusiness($business);
|
|
|
|
// Get lists for this business
|
|
$lists = MarketingList::where('business_id', $business->id)
|
|
->withCount('contacts')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
// Pre-populate from promo if provided
|
|
$promo = null;
|
|
if ($request->query('promo_id')) {
|
|
$promo = MarketingPromo::where('business_id', $business->id)
|
|
->find($request->query('promo_id'));
|
|
}
|
|
|
|
// Pre-select channel if provided
|
|
$preselectedChannel = $request->query('channel', 'email');
|
|
|
|
$channels = MarketingCampaign::CHANNELS;
|
|
|
|
return view('portal.campaigns.create', compact(
|
|
'business',
|
|
'branding',
|
|
'lists',
|
|
'promo',
|
|
'preselectedChannel',
|
|
'channels'
|
|
));
|
|
}
|
|
|
|
public function store(Request $request, Business $business)
|
|
{
|
|
$branding = BusinessBrandingSetting::forBusiness($business);
|
|
|
|
$validated = $request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'channel' => 'required|in:email,sms',
|
|
'list_id' => 'required|exists:marketing_lists,id',
|
|
'subject' => 'required_if:channel,email|nullable|string|max:255',
|
|
'body' => 'required|string',
|
|
'send_at' => 'nullable|date|after:now',
|
|
'promo_id' => 'nullable|exists:marketing_promos,id',
|
|
]);
|
|
|
|
// Verify list belongs to this business
|
|
$list = MarketingList::where('business_id', $business->id)
|
|
->findOrFail($validated['list_id']);
|
|
|
|
// Build campaign data
|
|
$campaignData = [
|
|
'business_id' => $business->id,
|
|
'name' => $validated['name'],
|
|
'channel' => $validated['channel'],
|
|
'list_id' => $list->id,
|
|
'subject' => $validated['subject'] ?? null,
|
|
'body' => $validated['body'],
|
|
'status' => 'draft',
|
|
'created_by' => Auth::id(),
|
|
// Use branding defaults for from fields
|
|
'from_name' => $branding->effective_from_name,
|
|
'from_email' => $branding->effective_from_email,
|
|
];
|
|
|
|
// Link to promo if provided
|
|
if (! empty($validated['promo_id'])) {
|
|
$promo = MarketingPromo::where('business_id', $business->id)
|
|
->find($validated['promo_id']);
|
|
|
|
if ($promo) {
|
|
$campaignData['source_type'] = 'promo';
|
|
$campaignData['source_id'] = $promo->id;
|
|
}
|
|
}
|
|
|
|
// Set schedule if provided
|
|
if (! empty($validated['send_at'])) {
|
|
$campaignData['send_at'] = $validated['send_at'];
|
|
$campaignData['status'] = 'scheduled';
|
|
}
|
|
|
|
$campaign = MarketingCampaign::create($campaignData);
|
|
|
|
if ($campaign->status === 'scheduled') {
|
|
return redirect()
|
|
->route('portal.campaigns.show', [$business->slug, $campaign])
|
|
->with('success', 'Campaign scheduled successfully.');
|
|
}
|
|
|
|
return redirect()
|
|
->route('portal.campaigns.show', [$business->slug, $campaign])
|
|
->with('success', 'Campaign created as draft. Review and send when ready.');
|
|
}
|
|
|
|
public function show(Request $request, Business $business, MarketingCampaign $campaign)
|
|
{
|
|
// Ensure campaign belongs to this business
|
|
if ($campaign->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$branding = BusinessBrandingSetting::forBusiness($business);
|
|
$campaign->load(['list', 'logs']);
|
|
|
|
// Get stats
|
|
$stats = [
|
|
'total_recipients' => $campaign->total_recipients,
|
|
'sent' => $campaign->total_sent,
|
|
'delivered' => $campaign->total_delivered,
|
|
'opened' => $campaign->total_opened,
|
|
'clicked' => $campaign->total_clicked,
|
|
'failed' => $campaign->total_failed,
|
|
];
|
|
|
|
return view('portal.campaigns.show', compact(
|
|
'business',
|
|
'branding',
|
|
'campaign',
|
|
'stats'
|
|
));
|
|
}
|
|
|
|
public function sendNow(Request $request, Business $business, MarketingCampaign $campaign)
|
|
{
|
|
// Ensure campaign belongs to this business
|
|
if ($campaign->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
|
return back()->with('error', 'This campaign cannot be sent.');
|
|
}
|
|
|
|
// Count recipients
|
|
$recipientCount = $campaign->list?->contacts()->count() ?? 0;
|
|
|
|
if ($recipientCount === 0) {
|
|
return back()->with('error', 'No recipients in the selected list.');
|
|
}
|
|
|
|
// Update campaign
|
|
$campaign->update([
|
|
'status' => 'sending',
|
|
'total_recipients' => $recipientCount,
|
|
'sent_at' => now(),
|
|
]);
|
|
|
|
// Dispatch job
|
|
SendMarketingCampaignJob::dispatch($campaign);
|
|
|
|
return redirect()
|
|
->route('portal.campaigns.show', [$business->slug, $campaign])
|
|
->with('success', "Campaign is now sending to {$recipientCount} recipients.");
|
|
}
|
|
|
|
public function schedule(Request $request, Business $business, MarketingCampaign $campaign)
|
|
{
|
|
// Ensure campaign belongs to this business
|
|
if ($campaign->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
if ($campaign->status !== 'draft') {
|
|
return back()->with('error', 'Only draft campaigns can be scheduled.');
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'send_at' => 'required|date|after:now',
|
|
]);
|
|
|
|
$campaign->update([
|
|
'status' => 'scheduled',
|
|
'send_at' => $validated['send_at'],
|
|
]);
|
|
|
|
return redirect()
|
|
->route('portal.campaigns.show', [$business->slug, $campaign])
|
|
->with('success', 'Campaign scheduled for '.$campaign->send_at->format('M j, Y g:i A'));
|
|
}
|
|
|
|
public function cancel(Request $request, Business $business, MarketingCampaign $campaign)
|
|
{
|
|
// Ensure campaign belongs to this business
|
|
if ($campaign->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
|
|
return back()->with('error', 'This campaign cannot be cancelled.');
|
|
}
|
|
|
|
$campaign->update([
|
|
'status' => 'cancelled',
|
|
]);
|
|
|
|
return redirect()
|
|
->route('portal.campaigns.index', $business->slug)
|
|
->with('success', 'Campaign cancelled.');
|
|
}
|
|
}
|