Compare commits
17 Commits
docs/add-f
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96dc58735f | ||
|
|
6b447f1deb | ||
|
|
297ddf1e6a | ||
|
|
5f28601192 | ||
|
|
f83677dc42 | ||
|
|
19352a914d | ||
|
|
e4a66f6049 | ||
|
|
2809b81a1e | ||
|
|
84f364de74 | ||
|
|
39c955cdc4 | ||
|
|
e02ca54187 | ||
|
|
ac46ee004b | ||
|
|
17a6eb260d | ||
|
|
5ea80366be | ||
|
|
99aa0cb980 | ||
|
|
3de53a76d0 | ||
|
|
7fa9b6aff8 |
157
CONTRIBUTING.md
157
CONTRIBUTING.md
@@ -239,6 +239,163 @@ git push origin feature/my-feature
|
||||
git push --no-verify
|
||||
```
|
||||
|
||||
### Keeping Your Feature Branch Up-to-Date
|
||||
|
||||
**Best practice for teams:** Sync your feature branch with `develop` regularly to avoid large merge conflicts.
|
||||
|
||||
#### Daily Start-of-Work Routine
|
||||
|
||||
```bash
|
||||
# 1. Get latest changes from develop
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
|
||||
# 2. Update your feature branch
|
||||
git checkout feature/my-feature
|
||||
git merge develop
|
||||
|
||||
# 3. If there are conflicts (see below), resolve them
|
||||
# 4. Continue working
|
||||
```
|
||||
|
||||
**How often?**
|
||||
- Minimum: Once per day (start of work)
|
||||
- Better: Multiple times per day if develop is active
|
||||
- Always: Before creating your Pull Request
|
||||
|
||||
#### Merge vs Rebase: Which to Use?
|
||||
|
||||
**For teams of 5+ developers, use `merge` (not `rebase`):**
|
||||
|
||||
```bash
|
||||
git checkout feature/my-feature
|
||||
git merge develop
|
||||
```
|
||||
|
||||
**Why merge over rebase?**
|
||||
- ✅ Safer: Preserves your commit history
|
||||
- ✅ Collaborative: Works when multiple people work on the same feature branch
|
||||
- ✅ Transparent: Shows when you integrated upstream changes
|
||||
- ✅ No force-push: Once you've pushed to origin, merge won't require `--force`
|
||||
|
||||
**When to use rebase:**
|
||||
- ⚠️ Only if you haven't pushed yet
|
||||
- ⚠️ Only if you're the sole developer on the branch
|
||||
- ⚠️ You want a cleaner, linear history
|
||||
|
||||
```bash
|
||||
# Only do this if you haven't pushed yet!
|
||||
git checkout feature/my-feature
|
||||
git rebase develop
|
||||
```
|
||||
|
||||
**Never rebase after pushing** - it rewrites history and breaks collaboration.
|
||||
|
||||
#### Handling Merge Conflicts
|
||||
|
||||
When you run `git merge develop` and see conflicts:
|
||||
|
||||
```bash
|
||||
$ git merge develop
|
||||
Auto-merging app/Http/Controllers/OrderController.php
|
||||
CONFLICT (content): Merge conflict in app/Http/Controllers/OrderController.php
|
||||
Automatic merge failed; fix conflicts and then commit the result.
|
||||
```
|
||||
|
||||
**Step-by-step resolution:**
|
||||
|
||||
1. **See which files have conflicts:**
|
||||
```bash
|
||||
git status
|
||||
# Look for "both modified:" files
|
||||
```
|
||||
|
||||
2. **Open conflicted files** - look for conflict markers:
|
||||
```php
|
||||
<<<<<<< HEAD
|
||||
// Your code
|
||||
=======
|
||||
// Code from develop
|
||||
>>>>>>> develop
|
||||
```
|
||||
|
||||
3. **Resolve conflicts** - edit the file to keep what you need:
|
||||
```php
|
||||
// Choose your code, their code, or combine both
|
||||
// Remove the <<<, ===, >>> markers
|
||||
```
|
||||
|
||||
4. **Mark as resolved:**
|
||||
```bash
|
||||
git add app/Http/Controllers/OrderController.php
|
||||
```
|
||||
|
||||
5. **Complete the merge:**
|
||||
```bash
|
||||
git commit -m "merge: resolve conflicts with develop"
|
||||
```
|
||||
|
||||
6. **Run tests to ensure nothing broke:**
|
||||
```bash
|
||||
./vendor/bin/sail artisan test
|
||||
```
|
||||
|
||||
7. **Push the merge commit:**
|
||||
```bash
|
||||
git push origin feature/my-feature
|
||||
```
|
||||
|
||||
#### When Conflicts Are Too Complex
|
||||
|
||||
If conflicts are extensive or you're unsure:
|
||||
|
||||
1. **Abort the merge:**
|
||||
```bash
|
||||
git merge --abort
|
||||
```
|
||||
|
||||
2. **Ask for help** in #engineering Slack:
|
||||
- "I'm merging develop into feature/X and have conflicts in OrderController"
|
||||
- Someone might have context on the upstream changes
|
||||
|
||||
3. **Pair program the resolution** - screen share with the person who made the conflicting changes
|
||||
|
||||
4. **Alternative: Start fresh** (last resort):
|
||||
```bash
|
||||
# Create new branch from latest develop
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/my-feature-v2
|
||||
|
||||
# Cherry-pick your commits
|
||||
git cherry-pick <commit-hash>
|
||||
```
|
||||
|
||||
#### Example: Multi-Day Feature Work
|
||||
|
||||
```bash
|
||||
# Monday morning
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout feature/payment-integration
|
||||
git merge develop # Get latest changes
|
||||
# Work all day, make commits
|
||||
|
||||
# Tuesday morning
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout feature/payment-integration
|
||||
git merge develop # Sync again (someone added auth changes)
|
||||
# Continue working
|
||||
|
||||
# Wednesday
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout feature/payment-integration
|
||||
git merge develop # Final sync before PR
|
||||
git push origin feature/payment-integration
|
||||
# Create Pull Request
|
||||
```
|
||||
|
||||
**Result:** Small, manageable syncs instead of one huge conflict on PR day.
|
||||
|
||||
### When to Test Locally
|
||||
|
||||
**Always run tests before pushing if you:**
|
||||
|
||||
@@ -26,7 +26,10 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
// Check for scheduled broadcasts every minute
|
||||
$schedule->job(new \App\Jobs\Marketing\ProcessScheduledBroadcastsJob())
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
28
app/Http/Controllers/Seller/DashboardV2Controller.php
Normal file
28
app/Http/Controllers/Seller/DashboardV2Controller.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardV2Controller extends Controller
|
||||
{
|
||||
public function index(Business $business)
|
||||
{
|
||||
// Get current view from session (sales, manufacturing, compliance)
|
||||
$currentView = session('current_view', 'sales');
|
||||
|
||||
// Load appropriate dashboard based on view
|
||||
$viewFile = match($currentView) {
|
||||
'manufacturing' => 'seller.dashboard-v2.manufacturing',
|
||||
'compliance' => 'seller.dashboard-v2.compliance',
|
||||
default => 'seller.dashboard-v2.sales',
|
||||
};
|
||||
|
||||
return view($viewFile, [
|
||||
'business' => $business,
|
||||
'currentView' => $currentView,
|
||||
]);
|
||||
}
|
||||
}
|
||||
454
app/Http/Controllers/Seller/Marketing/BroadcastController.php
Normal file
454
app/Http/Controllers/Seller/Marketing/BroadcastController.php
Normal file
@@ -0,0 +1,454 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Broadcast;
|
||||
use App\Models\MarketingAudience;
|
||||
use App\Models\MarketingTemplate;
|
||||
use App\Services\Marketing\BroadcastService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BroadcastController extends Controller
|
||||
{
|
||||
protected BroadcastService $broadcastService;
|
||||
|
||||
public function __construct(BroadcastService $broadcastService)
|
||||
{
|
||||
$this->broadcastService = $broadcastService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display list of broadcasts
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
$query = Broadcast::where('business_id', $business->id)
|
||||
->with('createdBy', 'template');
|
||||
|
||||
// Filter by status
|
||||
if ($request->has('status') && $request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// Filter by channel
|
||||
if ($request->has('channel') && $request->channel) {
|
||||
$query->where('channel', $request->channel);
|
||||
}
|
||||
|
||||
// Search
|
||||
if ($request->has('search') && $request->search) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('name', 'LIKE', "%{$request->search}%")
|
||||
->orWhere('description', 'LIKE', "%{$request->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$broadcasts = $query->orderBy('created_at', 'desc')->paginate(20);
|
||||
|
||||
return view('seller.marketing.broadcasts.index', compact('broadcasts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create form
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
$audiences = MarketingAudience::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$templates = MarketingTemplate::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.broadcasts.create', compact('audiences', 'templates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store new broadcast
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => 'required|in:immediate,scheduled',
|
||||
'channel' => 'required|in:email,sms,push,multi',
|
||||
'template_id' => 'nullable|exists:marketing_templates,id',
|
||||
'subject' => 'required_if:channel,email|nullable|string|max:255',
|
||||
'content' => 'required_without:template_id|nullable|string',
|
||||
'audience_ids' => 'nullable|array',
|
||||
'audience_ids.*' => 'exists:marketing_audiences,id',
|
||||
'include_all' => 'boolean',
|
||||
'exclude_audience_ids' => 'nullable|array',
|
||||
'scheduled_at' => 'required_if:type,scheduled|nullable|date|after:now',
|
||||
'timezone' => 'nullable|string',
|
||||
'track_opens' => 'boolean',
|
||||
'track_clicks' => 'boolean',
|
||||
'send_rate_limit' => 'nullable|integer|min:1|max:1000',
|
||||
]);
|
||||
|
||||
$broadcast = Broadcast::create([
|
||||
'business_id' => $business->id,
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
...$validated,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
// Prepare recipients
|
||||
try {
|
||||
$count = $this->broadcastService->prepareBroadcast($broadcast);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.broadcasts.show', $broadcast)
|
||||
->with('success', "Broadcast created with {$count} recipients");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['error' => 'Failed to prepare broadcast: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show specific broadcast
|
||||
*/
|
||||
public function show(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$broadcast->load(['createdBy', 'template', 'recipients' => function($query) {
|
||||
$query->with('user')->latest()->limit(50);
|
||||
}]);
|
||||
|
||||
$stats = $this->broadcastService->getStatistics($broadcast);
|
||||
|
||||
// Get event timeline (recent events)
|
||||
$recentEvents = $broadcast->events()
|
||||
->with('user')
|
||||
->latest('occurred_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.broadcasts.show', compact('broadcast', 'stats', 'recentEvents'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit form
|
||||
*/
|
||||
public function edit(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$broadcast->isDraft()) {
|
||||
return back()->with('error', 'Only draft broadcasts can be edited');
|
||||
}
|
||||
|
||||
$audiences = MarketingAudience::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$templates = MarketingTemplate::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.broadcasts.edit', compact('broadcast', 'audiences', 'templates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update broadcast
|
||||
*/
|
||||
public function update(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$broadcast->isDraft()) {
|
||||
return back()->with('error', 'Only draft broadcasts can be updated');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => 'required|in:immediate,scheduled',
|
||||
'channel' => 'required|in:email,sms,push,multi',
|
||||
'template_id' => 'nullable|exists:marketing_templates,id',
|
||||
'subject' => 'required_if:channel,email|nullable|string|max:255',
|
||||
'content' => 'required_without:template_id|nullable|string',
|
||||
'audience_ids' => 'nullable|array',
|
||||
'include_all' => 'boolean',
|
||||
'exclude_audience_ids' => 'nullable|array',
|
||||
'scheduled_at' => 'required_if:type,scheduled|nullable|date|after:now',
|
||||
'track_opens' => 'boolean',
|
||||
'track_clicks' => 'boolean',
|
||||
'send_rate_limit' => 'nullable|integer|min:1|max:1000',
|
||||
]);
|
||||
|
||||
$broadcast->update($validated);
|
||||
|
||||
// Re-prepare recipients
|
||||
try {
|
||||
$count = $this->broadcastService->prepareBroadcast($broadcast);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.broadcasts.show', $broadcast)
|
||||
->with('success', "Broadcast updated with {$count} recipients");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Failed to update broadcast: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete broadcast
|
||||
*/
|
||||
public function destroy(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!in_array($broadcast->status, ['draft', 'cancelled', 'failed'])) {
|
||||
return back()->with('error', 'Cannot delete broadcast in current status');
|
||||
}
|
||||
|
||||
$broadcast->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.broadcasts.index')
|
||||
->with('success', 'Broadcast deleted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send broadcast
|
||||
*/
|
||||
public function send(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->broadcastService->sendBroadcast($broadcast);
|
||||
|
||||
return back()->with('success', 'Broadcast is now being sent');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause broadcast
|
||||
*/
|
||||
public function pause(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->broadcastService->pauseBroadcast($broadcast);
|
||||
|
||||
return back()->with('success', 'Broadcast paused');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume broadcast
|
||||
*/
|
||||
public function resume(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->broadcastService->resumeBroadcast($broadcast);
|
||||
|
||||
return back()->with('success', 'Broadcast resumed');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel broadcast
|
||||
*/
|
||||
public function cancel(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->broadcastService->cancelBroadcast($broadcast);
|
||||
|
||||
return back()->with('success', 'Broadcast cancelled');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate broadcast
|
||||
*/
|
||||
public function duplicate(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$newBroadcast = $broadcast->replicate();
|
||||
$newBroadcast->name = $broadcast->name . ' (Copy)';
|
||||
$newBroadcast->status = 'draft';
|
||||
$newBroadcast->created_by_user_id = $request->user()->id;
|
||||
$newBroadcast->total_recipients = 0;
|
||||
$newBroadcast->total_sent = 0;
|
||||
$newBroadcast->total_delivered = 0;
|
||||
$newBroadcast->total_failed = 0;
|
||||
$newBroadcast->total_opened = 0;
|
||||
$newBroadcast->total_clicked = 0;
|
||||
$newBroadcast->started_sending_at = null;
|
||||
$newBroadcast->finished_sending_at = null;
|
||||
$newBroadcast->save();
|
||||
|
||||
// Prepare recipients
|
||||
$this->broadcastService->prepareBroadcast($newBroadcast);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.broadcasts.show', $newBroadcast)
|
||||
->with('success', 'Broadcast duplicated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress (AJAX)
|
||||
*/
|
||||
public function progress(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$stats = $this->broadcastService->getStatistics($broadcast);
|
||||
|
||||
return response()->json([
|
||||
'status' => $broadcast->status,
|
||||
'stats' => $stats,
|
||||
'progress' => $broadcast->total_recipients > 0
|
||||
? round(($broadcast->total_sent / $broadcast->total_recipients) * 100, 2)
|
||||
: 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View recipients
|
||||
*/
|
||||
public function recipients(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$recipients = $broadcast->recipients()
|
||||
->with('user')
|
||||
->when($request->has('status'), function($query) use ($request) {
|
||||
$query->where('status', $request->status);
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(50);
|
||||
|
||||
return view('seller.marketing.broadcasts.recipients', compact('broadcast', 'recipients'));
|
||||
}
|
||||
|
||||
/**
|
||||
* View analytics
|
||||
*/
|
||||
public function analytics(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$stats = $this->broadcastService->getStatistics($broadcast);
|
||||
|
||||
// Get hourly breakdown
|
||||
$hourlyData = DB::table('broadcast_recipients')
|
||||
->where('broadcast_id', $broadcast->id)
|
||||
->whereNotNull('sent_at')
|
||||
->select(
|
||||
DB::raw('DATE_FORMAT(sent_at, "%Y-%m-%d %H:00:00") as hour'),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('hour')
|
||||
->orderBy('hour')
|
||||
->get();
|
||||
|
||||
// Get event breakdown by type
|
||||
$eventBreakdown = DB::table('broadcast_events')
|
||||
->where('broadcast_id', $broadcast->id)
|
||||
->select('event', DB::raw('COUNT(*) as count'))
|
||||
->groupBy('event')
|
||||
->pluck('count', 'event');
|
||||
|
||||
// Top clicked links
|
||||
$topLinks = DB::table('broadcast_events')
|
||||
->where('broadcast_id', $broadcast->id)
|
||||
->where('event', 'clicked')
|
||||
->select('link_url', DB::raw('COUNT(*) as count'))
|
||||
->groupBy('link_url')
|
||||
->orderByDesc('count')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.broadcasts.analytics', compact(
|
||||
'broadcast',
|
||||
'stats',
|
||||
'hourlyData',
|
||||
'eventBreakdown',
|
||||
'topLinks'
|
||||
));
|
||||
}
|
||||
}
|
||||
507
app/Http/Controllers/Seller/Marketing/TemplateController.php
Normal file
507
app/Http/Controllers/Seller/Marketing/TemplateController.php
Normal file
@@ -0,0 +1,507 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Marketing\Template;
|
||||
use App\Models\Marketing\TemplateCategory;
|
||||
use App\Services\Marketing\AIContentService;
|
||||
use App\Services\Marketing\MergeTagService;
|
||||
use App\Services\Marketing\TemplateService;
|
||||
use App\Services\PermissionService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TemplateController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TemplateService $templateService,
|
||||
protected AIContentService $aiContentService,
|
||||
protected MergeTagService $mergeTagService,
|
||||
protected PermissionService $permissionService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('viewAny', [Template::class, $business]);
|
||||
|
||||
$query = Template::forBusiness($business->id)
|
||||
->with(['category', 'brands', 'analytics']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('name', 'ilike', '%' . $request->search . '%')
|
||||
->orWhere('description', 'ilike', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$query->where('category_id', $request->category);
|
||||
}
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->byType($request->type);
|
||||
}
|
||||
|
||||
if ($request->filled('brand')) {
|
||||
$query->whereHas('brands', fn ($q) => $q->where('brands.id', $request->brand));
|
||||
}
|
||||
|
||||
$sort = $request->get('sort', 'recent');
|
||||
match ($sort) {
|
||||
'popular' => $query->popular(),
|
||||
'name' => $query->orderBy('name'),
|
||||
default => $query->latest(),
|
||||
};
|
||||
|
||||
$templates = $query->paginate(24);
|
||||
|
||||
$categories = TemplateCategory::sorted()->get();
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
|
||||
return view('seller.marketing.templates.index', compact(
|
||||
'business',
|
||||
'templates',
|
||||
'categories',
|
||||
'brands'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('create', [Template::class, $business]);
|
||||
|
||||
$categories = TemplateCategory::sorted()->get();
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$mergeTags = $this->mergeTagService->getAvailableTags();
|
||||
|
||||
$templateType = $request->get('type', 'email');
|
||||
|
||||
return view('seller.marketing.templates.create', compact(
|
||||
'business',
|
||||
'categories',
|
||||
'brands',
|
||||
'mergeTags',
|
||||
'templateType'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('create', [Template::class, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'category_id' => 'required|exists:template_categories,id',
|
||||
'template_type' => 'required|in:email,sms,push',
|
||||
'design_json' => 'nullable|json',
|
||||
'mjml_content' => 'nullable|string',
|
||||
'html_content' => 'nullable|string',
|
||||
'plain_text' => 'nullable|string',
|
||||
'tags' => 'nullable|array',
|
||||
'tags.*' => 'string|max:50',
|
||||
'brands' => 'nullable|array',
|
||||
'brands.*' => 'exists:brands,id',
|
||||
]);
|
||||
|
||||
$template = $this->templateService->create($validated);
|
||||
|
||||
if (!empty($validated['brands'])) {
|
||||
foreach ($validated['brands'] as $brandId) {
|
||||
$this->templateService->addToBrand($template, $brandId);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.show', ['business' => $business, 'template' => $template])
|
||||
->with('success', 'Template created successfully');
|
||||
}
|
||||
|
||||
public function show(Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$template->load(['category', 'brands', 'analytics', 'creator', 'updater', 'versions']);
|
||||
|
||||
return view('seller.marketing.templates.show', compact('business', 'template'));
|
||||
}
|
||||
|
||||
public function edit(Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('update', [$template, $business]);
|
||||
|
||||
if (!$template->is_editable) {
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.show', ['business' => $business, 'template' => $template])
|
||||
->with('error', 'This template cannot be edited');
|
||||
}
|
||||
|
||||
$categories = TemplateCategory::sorted()->get();
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$mergeTags = $this->mergeTagService->getAvailableTags();
|
||||
|
||||
return view('seller.marketing.templates.edit', compact(
|
||||
'business',
|
||||
'template',
|
||||
'categories',
|
||||
'brands',
|
||||
'mergeTags'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('update', [$template, $business]);
|
||||
|
||||
if (!$template->is_editable) {
|
||||
return back()->with('error', 'This template cannot be edited');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'category_id' => 'required|exists:template_categories,id',
|
||||
'design_json' => 'nullable|json',
|
||||
'mjml_content' => 'nullable|string',
|
||||
'html_content' => 'nullable|string',
|
||||
'plain_text' => 'nullable|string',
|
||||
'tags' => 'nullable|array',
|
||||
'tags.*' => 'string|max:50',
|
||||
'change_notes' => 'nullable|string',
|
||||
'brands' => 'nullable|array',
|
||||
'brands.*' => 'exists:brands,id',
|
||||
]);
|
||||
|
||||
$this->templateService->update($template, $validated);
|
||||
|
||||
if (isset($validated['brands'])) {
|
||||
$template->brands()->sync($validated['brands']);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.show', ['business' => $business, 'template' => $template])
|
||||
->with('success', 'Template updated successfully');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('delete', [$template, $business]);
|
||||
|
||||
if (!$template->canBeDeleted()) {
|
||||
return back()->with('error', 'This template cannot be deleted because it is in use');
|
||||
}
|
||||
|
||||
$this->templateService->delete($template);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.index', ['business' => $business])
|
||||
->with('success', 'Template deleted successfully');
|
||||
}
|
||||
|
||||
public function duplicate(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('create', [Template::class, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
]);
|
||||
|
||||
$duplicate = $this->templateService->duplicate(
|
||||
$template,
|
||||
$validated['name'],
|
||||
$validated['brand_id'] ?? null
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.edit', ['business' => $business, 'template' => $duplicate])
|
||||
->with('success', 'Template duplicated successfully');
|
||||
}
|
||||
|
||||
public function preview(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$brand = null;
|
||||
if ($request->filled('brand_id')) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->findOrFail($request->brand_id);
|
||||
}
|
||||
|
||||
$sampleData = [
|
||||
'buyer' => (object) [
|
||||
'name' => 'John Doe',
|
||||
'first_name' => 'John',
|
||||
'last_name' => 'Doe',
|
||||
'email' => 'john@example.com',
|
||||
'phone' => '555-0123',
|
||||
],
|
||||
'order' => (object) [
|
||||
'order_number' => 'ORD-12345',
|
||||
'total' => '$299.99',
|
||||
'created_at' => now(),
|
||||
],
|
||||
'unsubscribe_link' => '#unsubscribe',
|
||||
'view_in_browser_link' => '#view-browser',
|
||||
];
|
||||
|
||||
$rendered = $this->templateService->render($template, $sampleData, $brand);
|
||||
|
||||
return response()->json(['html' => $rendered]);
|
||||
}
|
||||
|
||||
public function sendTest(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => 'required|email',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
]);
|
||||
|
||||
$brand = null;
|
||||
if (isset($validated['brand_id'])) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->findOrFail($validated['brand_id']);
|
||||
}
|
||||
|
||||
$sampleData = [
|
||||
'buyer' => (object) [
|
||||
'name' => auth()->user()->name,
|
||||
'email' => auth()->user()->email,
|
||||
],
|
||||
];
|
||||
|
||||
$rendered = $this->templateService->render($template, $sampleData, $brand);
|
||||
|
||||
// TODO: Integrate with mail system
|
||||
// Mail::send([], [], function ($message) use ($validated, $rendered, $template) {
|
||||
// $message->to($validated['email'])
|
||||
// ->subject('[TEST] ' . $template->name)
|
||||
// ->html($rendered);
|
||||
// });
|
||||
|
||||
return back()->with('success', 'Test email sent to ' . $validated['email']);
|
||||
}
|
||||
|
||||
public function analytics(Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$analytics = $template->analytics()
|
||||
->with('brand')
|
||||
->get();
|
||||
|
||||
$totalAnalytics = [
|
||||
'total_sends' => $analytics->sum('total_sends'),
|
||||
'total_opens' => $analytics->sum('total_opens'),
|
||||
'total_clicks' => $analytics->sum('total_clicks'),
|
||||
'total_bounces' => $analytics->sum('total_bounces'),
|
||||
'avg_open_rate' => $analytics->avg('avg_open_rate'),
|
||||
'avg_click_rate' => $analytics->avg('avg_click_rate'),
|
||||
];
|
||||
|
||||
return view('seller.marketing.templates.analytics', compact(
|
||||
'business',
|
||||
'template',
|
||||
'analytics',
|
||||
'totalAnalytics'
|
||||
));
|
||||
}
|
||||
|
||||
public function versions(Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$versions = $template->versions()
|
||||
->with('creator')
|
||||
->latest('version_number')
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.templates.versions', compact(
|
||||
'business',
|
||||
'template',
|
||||
'versions'
|
||||
));
|
||||
}
|
||||
|
||||
public function restoreVersion(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('update', [$template, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'version_id' => 'required|exists:template_versions,id',
|
||||
]);
|
||||
|
||||
$version = $template->versions()->findOrFail($validated['version_id']);
|
||||
$template->restoreVersion($version);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.edit', ['business' => $business, 'template' => $template])
|
||||
->with('success', 'Template restored to version ' . $version->version_number);
|
||||
}
|
||||
|
||||
public function addToBrand(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
]);
|
||||
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->findOrFail($validated['brand_id']);
|
||||
|
||||
$this->templateService->addToBrand($template, $brand->id);
|
||||
|
||||
return back()->with('success', 'Template added to brand');
|
||||
}
|
||||
|
||||
public function removeFromBrand(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
]);
|
||||
|
||||
$this->templateService->removeFromBrand($template, $validated['brand_id']);
|
||||
|
||||
return back()->with('success', 'Template removed from brand');
|
||||
}
|
||||
|
||||
public function toggleFavorite(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
]);
|
||||
|
||||
$this->templateService->toggleFavorite($template, $validated['brand_id']);
|
||||
|
||||
return back()->with('success', 'Favorite status updated');
|
||||
}
|
||||
|
||||
public function export(Business $business, Template $template, string $format)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
return match ($format) {
|
||||
'html' => response($this->templateService->exportToHtml($template))
|
||||
->header('Content-Type', 'text/html')
|
||||
->header('Content-Disposition', 'attachment; filename="' . $template->slug . '.html"'),
|
||||
|
||||
'mjml' => response($this->templateService->exportToMjml($template))
|
||||
->header('Content-Type', 'text/plain')
|
||||
->header('Content-Disposition', 'attachment; filename="' . $template->slug . '.mjml"'),
|
||||
|
||||
'zip' => response()
|
||||
->download($this->templateService->exportAsZip($template), $template->slug . '.zip')
|
||||
->deleteFileAfterSend(),
|
||||
|
||||
default => abort(400, 'Invalid export format'),
|
||||
};
|
||||
}
|
||||
|
||||
public function import(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('create', [Template::class, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'file' => 'required|file|mimes:html,txt,zip',
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'category_id' => 'required|exists:template_categories,id',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
|
||||
if ($extension === 'zip') {
|
||||
// TODO: Handle ZIP import with metadata extraction
|
||||
return back()->with('error', 'ZIP import not yet implemented');
|
||||
}
|
||||
|
||||
$html = file_get_contents($file->path());
|
||||
|
||||
$template = $this->templateService->importFromHtml($html, [
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'],
|
||||
'category_id' => $validated['category_id'],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.edit', ['business' => $business, 'template' => $template])
|
||||
->with('success', 'Template imported successfully');
|
||||
}
|
||||
|
||||
public function aiGenerate(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('create', [Template::class, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'prompt' => 'required|string|max:1000',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
]);
|
||||
|
||||
$context = ['business' => $business->name];
|
||||
|
||||
if (isset($validated['brand_id'])) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->findOrFail($validated['brand_id']);
|
||||
$context['brand'] = $brand->name;
|
||||
}
|
||||
|
||||
$content = $this->aiContentService->generateEmailContent(
|
||||
$validated['prompt'],
|
||||
$context
|
||||
);
|
||||
|
||||
return response()->json(['content' => $content]);
|
||||
}
|
||||
|
||||
public function aiSubjectLines(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'content' => 'required|string',
|
||||
'count' => 'nullable|integer|min:3|max:20',
|
||||
]);
|
||||
|
||||
$subjectLines = $this->aiContentService->generateSubjectLines(
|
||||
$validated['content'],
|
||||
$validated['count'] ?? 10
|
||||
);
|
||||
|
||||
return response()->json(['subject_lines' => $subjectLines]);
|
||||
}
|
||||
|
||||
public function aiImproveCopy(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'content' => 'required|string',
|
||||
'tone' => 'required|in:professional,casual,urgent,enthusiastic,educational',
|
||||
]);
|
||||
|
||||
$improved = $this->aiContentService->improveCopy(
|
||||
$validated['content'],
|
||||
$validated['tone']
|
||||
);
|
||||
|
||||
return response()->json(['improved' => $improved]);
|
||||
}
|
||||
|
||||
public function aiCheckSpam(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'content' => 'required|string',
|
||||
]);
|
||||
|
||||
$result = $this->aiContentService->checkSpamScore($validated['content']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class ProductLineController extends Controller
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index1', $business->slug)
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
->with('success', 'Product line created successfully.');
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ class ProductLineController extends Controller
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index1', $business->slug)
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
->with('success', 'Product line updated successfully.');
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class ProductLineController extends Controller
|
||||
$productLine->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index1', $business->slug)
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
->with('success', 'Product line deleted successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Traits\FileStorageHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class StorageTestController extends Controller
|
||||
{
|
||||
use FileStorageHelper;
|
||||
|
||||
/**
|
||||
* Test storage configuration
|
||||
*/
|
||||
public function test(Request $request)
|
||||
{
|
||||
$results = [];
|
||||
$results['storage_info'] = $this->getStorageInfo();
|
||||
|
||||
// Test file upload if provided
|
||||
if ($request->hasFile('test_file')) {
|
||||
try {
|
||||
$file = $request->file('test_file');
|
||||
|
||||
// Store test file
|
||||
$path = $this->storeFile($file, 'tests');
|
||||
$results['upload'] = [
|
||||
'success' => true,
|
||||
'path' => $path,
|
||||
'url' => $this->getFileUrl($path),
|
||||
];
|
||||
|
||||
// Verify file exists
|
||||
$disk = Storage::disk($this->getStorageDisk());
|
||||
$results['verification'] = [
|
||||
'exists' => $disk->exists($path),
|
||||
'size' => $disk->size($path),
|
||||
];
|
||||
|
||||
// Delete test file
|
||||
$deleted = $this->deleteFile($path);
|
||||
$results['cleanup'] = [
|
||||
'deleted' => $deleted,
|
||||
'still_exists' => $disk->exists($path),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$results['error'] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($results, 200, [], JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show test upload form
|
||||
*/
|
||||
public function form()
|
||||
{
|
||||
return view('storage-test');
|
||||
}
|
||||
}
|
||||
42
app/Jobs/Marketing/ProcessScheduledBroadcastsJob.php
Normal file
42
app/Jobs/Marketing/ProcessScheduledBroadcastsJob.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Marketing;
|
||||
|
||||
use App\Models\Broadcast;
|
||||
use App\Services\Marketing\BroadcastService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessScheduledBroadcastsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle(BroadcastService $service): void
|
||||
{
|
||||
$broadcasts = Broadcast::scheduled()->get();
|
||||
|
||||
Log::info('Processing scheduled broadcasts', [
|
||||
'count' => $broadcasts->count(),
|
||||
]);
|
||||
|
||||
foreach ($broadcasts as $broadcast) {
|
||||
try {
|
||||
$service->sendBroadcast($broadcast);
|
||||
|
||||
Log::info('Scheduled broadcast started', [
|
||||
'broadcast_id' => $broadcast->id,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to start scheduled broadcast', [
|
||||
'broadcast_id' => $broadcast->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
app/Jobs/Marketing/SendBroadcastJob.php
Normal file
62
app/Jobs/Marketing/SendBroadcastJob.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Marketing;
|
||||
|
||||
use App\Models\Broadcast;
|
||||
use App\Services\Marketing\BroadcastService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SendBroadcastJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
public int $timeout = 600; // 10 minutes
|
||||
|
||||
public function __construct(
|
||||
public Broadcast $broadcast
|
||||
) {}
|
||||
|
||||
public function handle(BroadcastService $service): void
|
||||
{
|
||||
try {
|
||||
Log::info('Starting broadcast send', [
|
||||
'broadcast_id' => $this->broadcast->id,
|
||||
'name' => $this->broadcast->name,
|
||||
]);
|
||||
|
||||
$service->processBroadcastSending($this->broadcast);
|
||||
|
||||
Log::info('Broadcast queued successfully', [
|
||||
'broadcast_id' => $this->broadcast->id,
|
||||
'recipients' => $this->broadcast->total_recipients,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Broadcast send failed', [
|
||||
'broadcast_id' => $this->broadcast->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->broadcast->update([
|
||||
'status' => 'failed',
|
||||
'finished_sending_at' => now(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('SendBroadcastJob failed permanently', [
|
||||
'broadcast_id' => $this->broadcast->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
app/Jobs/Marketing/SendBroadcastMessageJob.php
Normal file
55
app/Jobs/Marketing/SendBroadcastMessageJob.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Marketing;
|
||||
|
||||
use App\Models\Broadcast;
|
||||
use App\Models\BroadcastRecipient;
|
||||
use App\Services\Marketing\BroadcastService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SendBroadcastMessageJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $timeout = 60;
|
||||
public int $backoff = 30; // Retry after 30 seconds
|
||||
|
||||
public function __construct(
|
||||
public Broadcast $broadcast,
|
||||
public BroadcastRecipient $recipient
|
||||
) {}
|
||||
|
||||
public function handle(BroadcastService $service): void
|
||||
{
|
||||
// Check if broadcast is still sending
|
||||
if ($this->broadcast->status !== 'sending') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if recipient still needs sending
|
||||
if (!in_array($this->recipient->status, ['pending', 'queued'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the message
|
||||
$service->sendToRecipient($this->broadcast, $this->recipient);
|
||||
|
||||
// Check if broadcast is complete
|
||||
$service->checkBroadcastCompletion($this->broadcast);
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
$this->recipient->markAsFailed(
|
||||
$exception->getMessage(),
|
||||
$exception->getCode()
|
||||
);
|
||||
|
||||
$this->broadcast->increment('total_failed');
|
||||
}
|
||||
}
|
||||
23
app/Mail/BroadcastEmail.php
Normal file
23
app/Mail/BroadcastEmail.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BroadcastEmail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $emailSubject,
|
||||
public string $emailBody
|
||||
) {}
|
||||
|
||||
public function build()
|
||||
{
|
||||
return $this->subject($this->emailSubject)
|
||||
->html($this->emailBody);
|
||||
}
|
||||
}
|
||||
171
app/Models/Broadcast.php
Normal file
171
app/Models/Broadcast.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class Broadcast extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'created_by_user_id',
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'channel',
|
||||
'template_id',
|
||||
'subject',
|
||||
'content',
|
||||
'audience_ids',
|
||||
'segment_rules',
|
||||
'include_all',
|
||||
'exclude_audience_ids',
|
||||
'scheduled_at',
|
||||
'timezone',
|
||||
'recurring_pattern',
|
||||
'recurring_ends_at',
|
||||
'status',
|
||||
'started_sending_at',
|
||||
'finished_sending_at',
|
||||
'total_recipients',
|
||||
'total_sent',
|
||||
'total_delivered',
|
||||
'total_failed',
|
||||
'total_opened',
|
||||
'total_clicked',
|
||||
'total_unsubscribed',
|
||||
'track_opens',
|
||||
'track_clicks',
|
||||
'send_rate_limit',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'audience_ids' => 'array',
|
||||
'segment_rules' => 'array',
|
||||
'exclude_audience_ids' => 'array',
|
||||
'include_all' => 'boolean',
|
||||
'scheduled_at' => 'datetime',
|
||||
'recurring_ends_at' => 'datetime',
|
||||
'started_sending_at' => 'datetime',
|
||||
'finished_sending_at' => 'datetime',
|
||||
'recurring_pattern' => 'array',
|
||||
'track_opens' => 'boolean',
|
||||
'track_clicks' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MarketingTemplate::class, 'template_id');
|
||||
}
|
||||
|
||||
public function recipients(): HasMany
|
||||
{
|
||||
return $this->hasMany(BroadcastRecipient::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(BroadcastEvent::class);
|
||||
}
|
||||
|
||||
public function audiences()
|
||||
{
|
||||
if (!$this->audience_ids) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return MarketingAudience::whereIn('id', $this->audience_ids)->get();
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereIn('status', ['scheduled', 'sending']);
|
||||
}
|
||||
|
||||
public function scopeScheduled($query)
|
||||
{
|
||||
return $query->where('status', 'scheduled')
|
||||
->where('scheduled_at', '<=', now());
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->status === 'draft';
|
||||
}
|
||||
|
||||
public function isScheduled(): bool
|
||||
{
|
||||
return $this->status === 'scheduled';
|
||||
}
|
||||
|
||||
public function isSending(): bool
|
||||
{
|
||||
return $this->status === 'sending';
|
||||
}
|
||||
|
||||
public function isSent(): bool
|
||||
{
|
||||
return $this->status === 'sent';
|
||||
}
|
||||
|
||||
public function canBeSent(): bool
|
||||
{
|
||||
return in_array($this->status, ['draft', 'scheduled', 'paused']);
|
||||
}
|
||||
|
||||
public function canBeCancelled(): bool
|
||||
{
|
||||
return in_array($this->status, ['draft', 'scheduled', 'paused']);
|
||||
}
|
||||
|
||||
public function getOpenRate(): float
|
||||
{
|
||||
if ($this->total_delivered === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->total_opened / $this->total_delivered) * 100, 2);
|
||||
}
|
||||
|
||||
public function getClickRate(): float
|
||||
{
|
||||
if ($this->total_delivered === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->total_clicked / $this->total_delivered) * 100, 2);
|
||||
}
|
||||
|
||||
public function getDeliveryRate(): float
|
||||
{
|
||||
if ($this->total_sent === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->total_delivered / $this->total_sent) * 100, 2);
|
||||
}
|
||||
}
|
||||
60
app/Models/BroadcastEvent.php
Normal file
60
app/Models/BroadcastEvent.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class BroadcastEvent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'broadcast_id',
|
||||
'user_id',
|
||||
'event',
|
||||
'link_url',
|
||||
'user_agent',
|
||||
'ip_address',
|
||||
'device_type',
|
||||
'metadata',
|
||||
'occurred_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'occurred_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function broadcast(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Broadcast::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->event === 'opened';
|
||||
}
|
||||
|
||||
public function isClick(): bool
|
||||
{
|
||||
return $this->event === 'clicked';
|
||||
}
|
||||
|
||||
public function isUnsubscribe(): bool
|
||||
{
|
||||
return $this->event === 'unsubscribed';
|
||||
}
|
||||
}
|
||||
83
app/Models/BroadcastRecipient.php
Normal file
83
app/Models/BroadcastRecipient.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class BroadcastRecipient extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'broadcast_id',
|
||||
'user_id',
|
||||
'status',
|
||||
'queued_at',
|
||||
'sent_at',
|
||||
'delivered_at',
|
||||
'failed_at',
|
||||
'error_message',
|
||||
'error_code',
|
||||
'provider_message_id',
|
||||
'provider_response',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'queued_at' => 'datetime',
|
||||
'sent_at' => 'datetime',
|
||||
'delivered_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
'provider_response' => 'array',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function broadcast(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Broadcast::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function markAsQueued(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'queued',
|
||||
'queued_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsSent(string $providerId = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'sent',
|
||||
'sent_at' => now(),
|
||||
'provider_message_id' => $providerId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsDelivered(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'delivered',
|
||||
'delivered_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsFailed(string $error, string $code = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'failed',
|
||||
'failed_at' => now(),
|
||||
'error_message' => $error,
|
||||
'error_code' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
223
app/Models/Marketing/Template.php
Normal file
223
app/Models/Marketing/Template.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Brand;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Template extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'category_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'name',
|
||||
'description',
|
||||
'thumbnail',
|
||||
'design_json',
|
||||
'mjml_content',
|
||||
'html_content',
|
||||
'plain_text',
|
||||
'is_system_template',
|
||||
'is_public',
|
||||
'template_type',
|
||||
'tags',
|
||||
'usage_count',
|
||||
'last_used_at',
|
||||
'version',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'design_json' => 'array',
|
||||
'tags' => 'array',
|
||||
'is_system_template' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $appends = ['is_editable'];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TemplateCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
public function versions(): HasMany
|
||||
{
|
||||
return $this->hasMany(TemplateVersion::class)->orderBy('version_number', 'desc');
|
||||
}
|
||||
|
||||
public function analytics(): HasMany
|
||||
{
|
||||
return $this->hasMany(TemplateAnalytics::class);
|
||||
}
|
||||
|
||||
public function brands(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Brand::class, 'brand_templates')
|
||||
->withPivot(['is_favorite', 'usage_count', 'last_used_at', 'added_by'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function broadcasts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Broadcast::class, 'template_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where(function($q) use ($businessId) {
|
||||
$q->where('business_id', $businessId)
|
||||
->orWhere('is_system_template', true);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeSystemTemplates($query)
|
||||
{
|
||||
return $query->where('is_system_template', true);
|
||||
}
|
||||
|
||||
public function scopeUserTemplates($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId)
|
||||
->where('is_system_template', false);
|
||||
}
|
||||
|
||||
public function scopeByType($query, string $type)
|
||||
{
|
||||
return $query->where('template_type', $type);
|
||||
}
|
||||
|
||||
public function scopeByCategory($query, $categoryId)
|
||||
{
|
||||
return $query->where('category_id', $categoryId);
|
||||
}
|
||||
|
||||
public function scopeRecent($query)
|
||||
{
|
||||
return $query->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
public function scopePopular($query)
|
||||
{
|
||||
return $query->orderBy('usage_count', 'desc');
|
||||
}
|
||||
|
||||
public function scopeWithTags($query, array $tags)
|
||||
{
|
||||
return $query->where(function($q) use ($tags) {
|
||||
foreach ($tags as $tag) {
|
||||
$q->orWhereJsonContains('tags', $tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
public function getIsEditableAttribute(): bool
|
||||
{
|
||||
// System templates are read-only
|
||||
if ($this->is_system_template) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// User can edit their own business templates
|
||||
return $this->business_id === currentBusiness()?->id;
|
||||
}
|
||||
|
||||
public function canBeDeleted(): bool
|
||||
{
|
||||
// System templates cannot be deleted
|
||||
if ($this->is_system_template) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if template is in use
|
||||
if ($this->usage_count > 0 || $this->broadcasts()->exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function incrementUsage(): void
|
||||
{
|
||||
$this->increment('usage_count');
|
||||
$this->update(['last_used_at' => now()]);
|
||||
}
|
||||
|
||||
public function createVersion(?string $notes = null): TemplateVersion
|
||||
{
|
||||
return $this->versions()->create([
|
||||
'version_number' => $this->version,
|
||||
'version_name' => "Version {$this->version}",
|
||||
'change_notes' => $notes,
|
||||
'design_json' => $this->design_json,
|
||||
'mjml_content' => $this->mjml_content,
|
||||
'html_content' => $this->html_content,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function restoreVersion(TemplateVersion $version): void
|
||||
{
|
||||
$this->update([
|
||||
'design_json' => $version->design_json,
|
||||
'mjml_content' => $version->mjml_content,
|
||||
'html_content' => $version->html_content,
|
||||
'version' => $this->version + 1,
|
||||
]);
|
||||
|
||||
$this->createVersion("Restored from Version {$version->version_number}");
|
||||
}
|
||||
|
||||
public function duplicate(string $newName): self
|
||||
{
|
||||
$duplicate = $this->replicate();
|
||||
$duplicate->name = $newName;
|
||||
$duplicate->is_system_template = false;
|
||||
$duplicate->usage_count = 0;
|
||||
$duplicate->last_used_at = null;
|
||||
$duplicate->created_by = auth()->id();
|
||||
$duplicate->save();
|
||||
|
||||
return $duplicate;
|
||||
}
|
||||
|
||||
public function getAnalyticsForBusiness($businessId, $brandId = null): ?TemplateAnalytics
|
||||
{
|
||||
return $this->analytics()
|
||||
->where('business_id', $businessId)
|
||||
->where('brand_id', $brandId)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
96
app/Models/Marketing/TemplateAnalytics.php
Normal file
96
app/Models/Marketing/TemplateAnalytics.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TemplateAnalytics extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'business_id',
|
||||
'brand_id',
|
||||
'times_used',
|
||||
'total_sends',
|
||||
'total_opens',
|
||||
'total_clicks',
|
||||
'total_bounces',
|
||||
'total_unsubscribes',
|
||||
'avg_open_rate',
|
||||
'avg_click_rate',
|
||||
'avg_bounce_rate',
|
||||
'first_used_at',
|
||||
'last_used_at',
|
||||
'best_subject_line',
|
||||
'best_open_rate',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'first_used_at' => 'datetime',
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Template::class);
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function updateFromBroadcast(Broadcast $broadcast): void
|
||||
{
|
||||
$this->increment('times_used');
|
||||
$this->increment('total_sends', $broadcast->sent_count);
|
||||
|
||||
// Update engagement metrics
|
||||
$opens = $broadcast->interactions()->where('status', 'opened')->count();
|
||||
$clicks = $broadcast->interactions()->where('status', 'clicked')->count();
|
||||
$bounces = $broadcast->interactions()->where('status', 'bounced')->count();
|
||||
|
||||
$this->increment('total_opens', $opens);
|
||||
$this->increment('total_clicks', $clicks);
|
||||
$this->increment('total_bounces', $bounces);
|
||||
|
||||
// Recalculate averages
|
||||
$this->updateAverages();
|
||||
|
||||
// Update best performing
|
||||
$openRate = $broadcast->open_rate;
|
||||
if ($openRate > $this->best_open_rate) {
|
||||
$this->update([
|
||||
'best_open_rate' => $openRate,
|
||||
'best_subject_line' => $broadcast->subject,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'last_used_at' => now(),
|
||||
'first_used_at' => $this->first_used_at ?? now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function updateAverages(): void
|
||||
{
|
||||
if ($this->total_sends > 0) {
|
||||
$this->update([
|
||||
'avg_open_rate' => round(($this->total_opens / $this->total_sends) * 100, 2),
|
||||
'avg_click_rate' => round(($this->total_clicks / $this->total_sends) * 100, 2),
|
||||
'avg_bounce_rate' => round(($this->total_bounces / $this->total_sends) * 100, 2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
app/Models/Marketing/TemplateBlock.php
Normal file
55
app/Models/Marketing/TemplateBlock.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class TemplateBlock extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'created_by',
|
||||
'name',
|
||||
'description',
|
||||
'thumbnail',
|
||||
'block_type',
|
||||
'design_json',
|
||||
'usage_count',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'design_json' => 'array',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeByType($query, string $type)
|
||||
{
|
||||
return $query->where('block_type', $type);
|
||||
}
|
||||
|
||||
public function incrementUsage(): void
|
||||
{
|
||||
$this->increment('usage_count');
|
||||
}
|
||||
}
|
||||
36
app/Models/Marketing/TemplateCategory.php
Normal file
36
app/Models/Marketing/TemplateCategory.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class TemplateCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'icon',
|
||||
'color',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
public function templates(): HasMany
|
||||
{
|
||||
return $this->hasMany(Template::class, 'category_id');
|
||||
}
|
||||
|
||||
public function scopeSorted($query)
|
||||
{
|
||||
return $query->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function getTemplateCountAttribute(): int
|
||||
{
|
||||
return $this->templates()->count();
|
||||
}
|
||||
}
|
||||
38
app/Models/Marketing/TemplateVersion.php
Normal file
38
app/Models/Marketing/TemplateVersion.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TemplateVersion extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'created_by',
|
||||
'version_number',
|
||||
'version_name',
|
||||
'change_notes',
|
||||
'design_json',
|
||||
'mjml_content',
|
||||
'html_content',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'design_json' => 'array',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Template::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,14 @@ class ProductImage extends Model
|
||||
'path',
|
||||
'type',
|
||||
'order',
|
||||
'sort_order',
|
||||
'is_primary',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_primary' => 'boolean',
|
||||
'order' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function product(): BelongsTo
|
||||
|
||||
175
app/Services/Marketing/AIContentService.php
Normal file
175
app/Services/Marketing/AIContentService.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class AIContentService
|
||||
{
|
||||
protected string $apiKey;
|
||||
protected string $model = 'claude-sonnet-4-20250514';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = config('services.anthropic.api_key');
|
||||
}
|
||||
|
||||
public function generateEmailContent(string $prompt, array $context = []): string
|
||||
{
|
||||
$systemPrompt = $this->buildSystemPrompt('email', $context);
|
||||
|
||||
return $this->complete($systemPrompt, $prompt);
|
||||
}
|
||||
|
||||
public function generateSubjectLines(string $emailContent, int $count = 10): array
|
||||
{
|
||||
$prompt = "Generate {$count} compelling email subject lines for the following email content. Return only the subject lines, one per line, without numbering:\n\n{$emailContent}";
|
||||
|
||||
$response = $this->complete(
|
||||
'You are an expert email marketer specializing in cannabis industry marketing.',
|
||||
$prompt
|
||||
);
|
||||
|
||||
return array_filter(explode("\n", $response));
|
||||
}
|
||||
|
||||
public function improveCopy(string $content, string $tone = 'professional'): string
|
||||
{
|
||||
$prompt = "Improve the following email copy to be more engaging and {$tone}. Maintain the core message but enhance clarity and impact:\n\n{$content}";
|
||||
|
||||
return $this->complete(
|
||||
'You are an expert copywriter specializing in email marketing.',
|
||||
$prompt
|
||||
);
|
||||
}
|
||||
|
||||
public function generateProductDescription(array $productData): string
|
||||
{
|
||||
$prompt = "Write a compelling product description for:\n\n";
|
||||
$prompt .= "Product: " . ($productData['name'] ?? 'Unknown') . "\n";
|
||||
$prompt .= "Category: " . ($productData['category'] ?? 'Cannabis product') . "\n";
|
||||
|
||||
if (isset($productData['thc'])) {
|
||||
$prompt .= "THC: " . $productData['thc'] . "%\n";
|
||||
}
|
||||
if (isset($productData['cbd'])) {
|
||||
$prompt .= "CBD: " . $productData['cbd'] . "%\n";
|
||||
}
|
||||
|
||||
$prompt .= "\nCreate a 2-3 paragraph description that highlights benefits and appeals to cannabis consumers.";
|
||||
|
||||
return $this->complete(
|
||||
'You are an expert product copywriter for the cannabis industry.',
|
||||
$prompt
|
||||
);
|
||||
}
|
||||
|
||||
public function generateCTA(string $goal, string $context = ''): array
|
||||
{
|
||||
$prompt = "Generate 5 different call-to-action button texts for: {$goal}";
|
||||
if ($context) {
|
||||
$prompt .= "\nContext: {$context}";
|
||||
}
|
||||
$prompt .= "\n\nReturn only the CTA texts, one per line, without numbering. Keep each CTA under 4 words.";
|
||||
|
||||
$response = $this->complete(
|
||||
'You are an expert at writing compelling call-to-action copy.',
|
||||
$prompt
|
||||
);
|
||||
|
||||
return array_filter(explode("\n", $response));
|
||||
}
|
||||
|
||||
public function adjustTone(string $content, string $targetTone): string
|
||||
{
|
||||
$tones = [
|
||||
'professional' => 'formal, business-like, and authoritative',
|
||||
'casual' => 'friendly, conversational, and approachable',
|
||||
'urgent' => 'time-sensitive, action-oriented, and compelling',
|
||||
'enthusiastic' => 'excited, energetic, and positive',
|
||||
'educational' => 'informative, clear, and helpful',
|
||||
];
|
||||
|
||||
$toneDescription = $tones[$targetTone] ?? $targetTone;
|
||||
|
||||
$prompt = "Rewrite the following content to have a {$toneDescription} tone. Maintain the core message:\n\n{$content}";
|
||||
|
||||
return $this->complete(
|
||||
'You are an expert content editor.',
|
||||
$prompt
|
||||
);
|
||||
}
|
||||
|
||||
public function checkSpamScore(string $content): array
|
||||
{
|
||||
$prompt = "Analyze this email content for spam trigger words and phrases. Provide:\n";
|
||||
$prompt .= "1. Spam score (0-10, where 0 is good and 10 is very spammy)\n";
|
||||
$prompt .= "2. List of problematic words/phrases found\n";
|
||||
$prompt .= "3. Suggestions to improve\n\n";
|
||||
$prompt .= "Content:\n{$content}\n\n";
|
||||
$prompt .= "Respond in JSON format: {\"score\": 0, \"triggers\": [], \"suggestions\": []}";
|
||||
|
||||
$response = $this->complete(
|
||||
'You are an email deliverability expert.',
|
||||
$prompt
|
||||
);
|
||||
|
||||
$response = trim($response);
|
||||
$response = preg_replace('/```json\n?/', '', $response);
|
||||
$response = preg_replace('/```\n?/', '', $response);
|
||||
|
||||
return json_decode($response, true) ?? [
|
||||
'score' => 0,
|
||||
'triggers' => [],
|
||||
'suggestions' => []
|
||||
];
|
||||
}
|
||||
|
||||
protected function complete(string $systemPrompt, string $userPrompt): string
|
||||
{
|
||||
$response = Http::withHeaders([
|
||||
'x-api-key' => $this->apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'content-type' => 'application/json',
|
||||
])->post('https://api.anthropic.com/v1/messages', [
|
||||
'model' => $this->model,
|
||||
'max_tokens' => 2000,
|
||||
'system' => $systemPrompt,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $userPrompt,
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new \Exception('AI API error: ' . $response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return $data['content'][0]['text'] ?? '';
|
||||
}
|
||||
|
||||
protected function buildSystemPrompt(string $type, array $context): string
|
||||
{
|
||||
$prompts = [
|
||||
'email' => 'You are an expert email marketing copywriter specializing in the cannabis industry. You write compelling, conversion-focused email content that complies with cannabis marketing regulations.',
|
||||
];
|
||||
|
||||
$prompt = $prompts[$type] ?? 'You are a helpful AI assistant.';
|
||||
|
||||
if (!empty($context['business'])) {
|
||||
$prompt .= "\n\nBusiness: " . $context['business'];
|
||||
}
|
||||
if (!empty($context['brand'])) {
|
||||
$prompt .= "\nBrand: " . $context['brand'];
|
||||
}
|
||||
if (!empty($context['audience'])) {
|
||||
$prompt .= "\nTarget Audience: " . $context['audience'];
|
||||
}
|
||||
|
||||
return $prompt;
|
||||
}
|
||||
}
|
||||
442
app/Services/Marketing/BroadcastService.php
Normal file
442
app/Services/Marketing/BroadcastService.php
Normal file
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use App\Models\Broadcast;
|
||||
use App\Models\BroadcastRecipient;
|
||||
use App\Models\BroadcastEvent;
|
||||
use App\Models\User;
|
||||
use App\Models\MarketingAudience;
|
||||
use App\Jobs\Marketing\SendBroadcastJob;
|
||||
use App\Jobs\Marketing\SendBroadcastMessageJob;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BroadcastService
|
||||
{
|
||||
protected TemplateRenderingService $templateService;
|
||||
|
||||
public function __construct(TemplateRenderingService $templateService)
|
||||
{
|
||||
$this->templateService = $templateService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate recipients for broadcast
|
||||
*/
|
||||
public function calculateRecipients(Broadcast $broadcast): Collection
|
||||
{
|
||||
$query = User::query()
|
||||
->where('business_id', $broadcast->business_id)
|
||||
->where('role', 'buyer')
|
||||
->where('is_active', true);
|
||||
|
||||
// Include all buyers
|
||||
if ($broadcast->include_all) {
|
||||
// Apply exclusions if any
|
||||
if ($broadcast->exclude_audience_ids) {
|
||||
$excludedUserIds = MarketingAudience::whereIn('id', $broadcast->exclude_audience_ids)
|
||||
->get()
|
||||
->flatMap(fn($audience) => $audience->members->pluck('id'))
|
||||
->unique();
|
||||
|
||||
if ($excludedUserIds->isNotEmpty()) {
|
||||
$query->whereNotIn('id', $excludedUserIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Include specific audiences
|
||||
elseif ($broadcast->audience_ids) {
|
||||
$userIds = MarketingAudience::whereIn('id', $broadcast->audience_ids)
|
||||
->get()
|
||||
->flatMap(fn($audience) => $audience->members->pluck('id'))
|
||||
->unique();
|
||||
|
||||
$query->whereIn('id', $userIds);
|
||||
}
|
||||
// Apply custom segment rules
|
||||
elseif ($broadcast->segment_rules) {
|
||||
foreach ($broadcast->segment_rules as $rule) {
|
||||
$this->applySegmentRule($query, $rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by channel preference (if user has unsubscribed)
|
||||
$query->where(function($q) use ($broadcast) {
|
||||
$q->whereNull('unsubscribed_from_' . $broadcast->channel)
|
||||
->orWhere('unsubscribed_from_' . $broadcast->channel, false);
|
||||
});
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply segment rule to query
|
||||
*/
|
||||
protected function applySegmentRule($query, array $rule): void
|
||||
{
|
||||
$field = $rule['field'];
|
||||
$operator = $rule['operator'];
|
||||
$value = $rule['value'];
|
||||
|
||||
switch ($operator) {
|
||||
case '=':
|
||||
$query->where($field, $value);
|
||||
break;
|
||||
case '!=':
|
||||
$query->where($field, '!=', $value);
|
||||
break;
|
||||
case '>':
|
||||
$query->where($field, '>', $value);
|
||||
break;
|
||||
case '<':
|
||||
$query->where($field, '<', $value);
|
||||
break;
|
||||
case 'contains':
|
||||
$query->where($field, 'LIKE', "%{$value}%");
|
||||
break;
|
||||
case 'in':
|
||||
$query->whereIn($field, (array)$value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare broadcast for sending
|
||||
*/
|
||||
public function prepareBroadcast(Broadcast $broadcast): int
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
// Calculate recipients
|
||||
$recipients = $this->calculateRecipients($broadcast);
|
||||
|
||||
// Clear existing recipients if re-preparing
|
||||
$broadcast->recipients()->delete();
|
||||
|
||||
// Create recipient records
|
||||
$recipientData = $recipients->map(fn($user) => [
|
||||
'broadcast_id' => $broadcast->id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'pending',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])->toArray();
|
||||
|
||||
BroadcastRecipient::insert($recipientData);
|
||||
|
||||
// Update broadcast stats
|
||||
$broadcast->update([
|
||||
'total_recipients' => $recipients->count(),
|
||||
'status' => $broadcast->type === 'scheduled' ? 'scheduled' : 'draft',
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $recipients->count();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send broadcast immediately
|
||||
*/
|
||||
public function sendBroadcast(Broadcast $broadcast): void
|
||||
{
|
||||
if (!$broadcast->canBeSent()) {
|
||||
throw new \Exception("Broadcast cannot be sent in current status: {$broadcast->status}");
|
||||
}
|
||||
|
||||
// Update status
|
||||
$broadcast->update([
|
||||
'status' => 'sending',
|
||||
'started_sending_at' => now(),
|
||||
]);
|
||||
|
||||
// Dispatch main sending job
|
||||
SendBroadcastJob::dispatch($broadcast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process broadcast sending (called by queue job)
|
||||
*/
|
||||
public function processBroadcastSending(Broadcast $broadcast): void
|
||||
{
|
||||
$recipients = $broadcast->recipients()
|
||||
->where('status', 'pending')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$rateLimit = $broadcast->send_rate_limit ?? 100; // Default 100/min
|
||||
$delayPerMessage = 60 / $rateLimit; // Seconds between messages
|
||||
|
||||
foreach ($recipients as $index => $recipient) {
|
||||
$delay = $index * $delayPerMessage;
|
||||
|
||||
SendBroadcastMessageJob::dispatch($broadcast, $recipient)
|
||||
->delay(now()->addSeconds($delay));
|
||||
|
||||
$recipient->markAsQueued();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to individual recipient
|
||||
*/
|
||||
public function sendToRecipient(Broadcast $broadcast, BroadcastRecipient $recipient): void
|
||||
{
|
||||
try {
|
||||
$user = $recipient->user;
|
||||
|
||||
// Render content with variables
|
||||
$content = $this->renderContent($broadcast, $user);
|
||||
|
||||
// Send via appropriate channel
|
||||
$messageId = match($broadcast->channel) {
|
||||
'email' => $this->sendEmail($user, $content),
|
||||
'sms' => $this->sendSMS($user, $content),
|
||||
'push' => $this->sendPush($user, $content),
|
||||
'multi' => $this->sendMultiChannel($user, $content),
|
||||
};
|
||||
|
||||
// Mark as sent
|
||||
$recipient->markAsSent($messageId);
|
||||
|
||||
// Update broadcast stats
|
||||
$broadcast->increment('total_sent');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$recipient->markAsFailed($e->getMessage(), $e->getCode());
|
||||
$broadcast->increment('total_failed');
|
||||
|
||||
\Log::error('Broadcast send failed', [
|
||||
'broadcast_id' => $broadcast->id,
|
||||
'recipient_id' => $recipient->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content with variables
|
||||
*/
|
||||
protected function renderContent(Broadcast $broadcast, User $user): array
|
||||
{
|
||||
$context = [
|
||||
'customer' => $user,
|
||||
'business' => $broadcast->business,
|
||||
'unsubscribe_url' => route('unsubscribe', ['user' => $user->id, 'channel' => $broadcast->channel]),
|
||||
];
|
||||
|
||||
if ($broadcast->template) {
|
||||
return $this->templateService->render($broadcast->template, $context);
|
||||
}
|
||||
|
||||
// Use broadcast content directly
|
||||
return [
|
||||
'subject' => $this->templateService->replaceVariables($broadcast->subject ?? '', $context),
|
||||
'body' => $this->templateService->replaceVariables($broadcast->content ?? '', $context),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email
|
||||
*/
|
||||
protected function sendEmail(User $user, array $content): string
|
||||
{
|
||||
// Integration with your email service (e.g., SendGrid, SES, Mailgun)
|
||||
// This is a placeholder - implement based on your email provider
|
||||
|
||||
\Mail::to($user->email)->send(
|
||||
new \App\Mail\BroadcastEmail($content['subject'], $content['body'])
|
||||
);
|
||||
|
||||
return 'email-' . uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMS
|
||||
*/
|
||||
protected function sendSMS(User $user, array $content): string
|
||||
{
|
||||
// Integration with SMS service (e.g., Twilio, SNS)
|
||||
// Placeholder implementation
|
||||
|
||||
return 'sms-' . uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification
|
||||
*/
|
||||
protected function sendPush(User $user, array $content): string
|
||||
{
|
||||
// Integration with push service (e.g., FCM, OneSignal)
|
||||
// Placeholder implementation
|
||||
|
||||
return 'push-' . uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send multi-channel
|
||||
*/
|
||||
protected function sendMultiChannel(User $user, array $content): string
|
||||
{
|
||||
// Send through multiple channels
|
||||
$messageIds = [];
|
||||
|
||||
try {
|
||||
$messageIds['email'] = $this->sendEmail($user, $content);
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Multi-channel email failed', ['user_id' => $user->id]);
|
||||
}
|
||||
|
||||
try {
|
||||
$messageIds['sms'] = $this->sendSMS($user, $content);
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Multi-channel SMS failed', ['user_id' => $user->id]);
|
||||
}
|
||||
|
||||
return json_encode($messageIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check broadcast completion
|
||||
*/
|
||||
public function checkBroadcastCompletion(Broadcast $broadcast): void
|
||||
{
|
||||
$pendingCount = $broadcast->recipients()
|
||||
->whereIn('status', ['pending', 'queued', 'sending'])
|
||||
->count();
|
||||
|
||||
if ($pendingCount === 0) {
|
||||
$broadcast->update([
|
||||
'status' => 'sent',
|
||||
'finished_sending_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel broadcast
|
||||
*/
|
||||
public function cancelBroadcast(Broadcast $broadcast): void
|
||||
{
|
||||
if (!$broadcast->canBeCancelled()) {
|
||||
throw new \Exception('Broadcast cannot be cancelled');
|
||||
}
|
||||
|
||||
DB::transaction(function() use ($broadcast) {
|
||||
// Update pending recipients
|
||||
$broadcast->recipients()
|
||||
->whereIn('status', ['pending', 'queued'])
|
||||
->update(['status' => 'skipped']);
|
||||
|
||||
// Update broadcast
|
||||
$broadcast->update([
|
||||
'status' => 'cancelled',
|
||||
'finished_sending_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause broadcast
|
||||
*/
|
||||
public function pauseBroadcast(Broadcast $broadcast): void
|
||||
{
|
||||
if ($broadcast->status !== 'sending') {
|
||||
throw new \Exception('Only sending broadcasts can be paused');
|
||||
}
|
||||
|
||||
$broadcast->update(['status' => 'paused']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume broadcast
|
||||
*/
|
||||
public function resumeBroadcast(Broadcast $broadcast): void
|
||||
{
|
||||
if ($broadcast->status !== 'paused') {
|
||||
throw new \Exception('Only paused broadcasts can be resumed');
|
||||
}
|
||||
|
||||
$broadcast->update(['status' => 'sending']);
|
||||
|
||||
// Re-queue pending recipients
|
||||
$this->processBroadcastSending($broadcast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track event (open, click, etc)
|
||||
*/
|
||||
public function trackEvent(Broadcast $broadcast, User $user, string $event, array $data = []): void
|
||||
{
|
||||
BroadcastEvent::create([
|
||||
'broadcast_id' => $broadcast->id,
|
||||
'user_id' => $user->id,
|
||||
'event' => $event,
|
||||
'link_url' => $data['url'] ?? null,
|
||||
'user_agent' => $data['user_agent'] ?? request()->userAgent(),
|
||||
'ip_address' => $data['ip'] ?? request()->ip(),
|
||||
'device_type' => $this->detectDeviceType($data['user_agent'] ?? null),
|
||||
'metadata' => $data['metadata'] ?? null,
|
||||
]);
|
||||
|
||||
// Update broadcast stats
|
||||
match($event) {
|
||||
'opened' => $broadcast->increment('total_opened'),
|
||||
'clicked' => $broadcast->increment('total_clicked'),
|
||||
'unsubscribed' => $broadcast->increment('total_unsubscribed'),
|
||||
'delivered' => $broadcast->increment('total_delivered'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect device type from user agent
|
||||
*/
|
||||
protected function detectDeviceType(?string $userAgent): ?string
|
||||
{
|
||||
if (!$userAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/mobile/i', $userAgent)) {
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
if (preg_match('/tablet/i', $userAgent)) {
|
||||
return 'tablet';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get broadcast statistics
|
||||
*/
|
||||
public function getStatistics(Broadcast $broadcast): array
|
||||
{
|
||||
return [
|
||||
'total_recipients' => $broadcast->total_recipients,
|
||||
'total_sent' => $broadcast->total_sent,
|
||||
'total_delivered' => $broadcast->total_delivered,
|
||||
'total_failed' => $broadcast->total_failed,
|
||||
'total_opened' => $broadcast->total_opened,
|
||||
'total_clicked' => $broadcast->total_clicked,
|
||||
'total_unsubscribed' => $broadcast->total_unsubscribed,
|
||||
'open_rate' => $broadcast->getOpenRate(),
|
||||
'click_rate' => $broadcast->getClickRate(),
|
||||
'delivery_rate' => $broadcast->getDeliveryRate(),
|
||||
'status_breakdown' => $broadcast->recipients()
|
||||
->select('status', DB::raw('count(*) as count'))
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status')
|
||||
->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
129
app/Services/Marketing/MergeTagService.php
Normal file
129
app/Services/Marketing/MergeTagService.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MergeTagService
|
||||
{
|
||||
public function getAvailableTags(?Brand $brand = null): array
|
||||
{
|
||||
$tags = [
|
||||
'{{buyer_name}}' => 'Buyer full name',
|
||||
'{{buyer_first_name}}' => 'Buyer first name',
|
||||
'{{buyer_last_name}}' => 'Buyer last name',
|
||||
'{{buyer_email}}' => 'Buyer email address',
|
||||
'{{buyer_phone}}' => 'Buyer phone number',
|
||||
'{{business_name}}' => 'Your business name',
|
||||
'{{business_email}}' => 'Business email',
|
||||
'{{business_phone}}' => 'Business phone',
|
||||
'{{business_address}}' => 'Business address',
|
||||
'{{business_website}}' => 'Business website',
|
||||
'{{order_number}}' => 'Order number',
|
||||
'{{order_total}}' => 'Order total amount',
|
||||
'{{order_date}}' => 'Order date',
|
||||
'{{unsubscribe_link}}' => 'Unsubscribe URL',
|
||||
'{{view_in_browser}}' => 'View in browser URL',
|
||||
'{{current_year}}' => 'Current year',
|
||||
'{{current_date}}' => 'Current date',
|
||||
];
|
||||
|
||||
if ($brand) {
|
||||
$tags = array_merge($tags, [
|
||||
'{{brand_name}}' => 'Brand name',
|
||||
'{{brand_logo}}' => 'Brand logo URL',
|
||||
'{{brand_email}}' => 'Brand email',
|
||||
'{{brand_phone}}' => 'Brand phone',
|
||||
'{{brand_address}}' => 'Brand address',
|
||||
'{{brand_primary_color}}' => 'Brand primary color',
|
||||
'{{brand_secondary_color}}' => 'Brand secondary color',
|
||||
]);
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
public function replace(
|
||||
string $content,
|
||||
array $data = [],
|
||||
?Business $business = null,
|
||||
?Brand $brand = null
|
||||
): string {
|
||||
$business = $business ?? currentBusiness();
|
||||
$replacements = [];
|
||||
|
||||
if (isset($data['buyer'])) {
|
||||
$buyer = $data['buyer'];
|
||||
$replacements['{{buyer_name}}'] = $buyer->name ?? '';
|
||||
$replacements['{{buyer_first_name}}'] = $buyer->first_name ?? Str::before($buyer->name ?? '', ' ');
|
||||
$replacements['{{buyer_last_name}}'] = $buyer->last_name ?? Str::after($buyer->name ?? '', ' ');
|
||||
$replacements['{{buyer_email}}'] = $buyer->email ?? '';
|
||||
$replacements['{{buyer_phone}}'] = $buyer->phone ?? '';
|
||||
}
|
||||
|
||||
if ($business) {
|
||||
$replacements['{{business_name}}'] = $business->name;
|
||||
$replacements['{{business_email}}'] = $business->email ?? '';
|
||||
$replacements['{{business_phone}}'] = $business->phone ?? '';
|
||||
$replacements['{{business_address}}'] = $business->address ?? '';
|
||||
$replacements['{{business_website}}'] = $business->website ?? '';
|
||||
}
|
||||
|
||||
if ($brand) {
|
||||
$brandKit = $brand->defaultBrandKit;
|
||||
|
||||
$replacements['{{brand_name}}'] = $brand->name;
|
||||
$replacements['{{brand_logo}}'] = $brandKit?->getLogoUrl() ?? '';
|
||||
$replacements['{{brand_email}}'] = $brand->email ?? '';
|
||||
$replacements['{{brand_phone}}'] = $brand->phone ?? '';
|
||||
$replacements['{{brand_address}}'] = $brand->address ?? '';
|
||||
$replacements['{{brand_primary_color}}'] = $brandKit?->getPrimaryColor() ?? '#000000';
|
||||
$replacements['{{brand_secondary_color}}'] = $brandKit?->getAllColors()['secondary'] ?? '#666666';
|
||||
}
|
||||
|
||||
if (isset($data['order'])) {
|
||||
$order = $data['order'];
|
||||
$replacements['{{order_number}}'] = $order->order_number ?? '';
|
||||
$replacements['{{order_total}}'] = $order->total ?? '';
|
||||
$replacements['{{order_date}}'] = $order->created_at?->format('M d, Y') ?? '';
|
||||
}
|
||||
|
||||
$replacements['{{unsubscribe_link}}'] = $data['unsubscribe_link'] ?? '#';
|
||||
$replacements['{{view_in_browser}}'] = $data['view_in_browser_link'] ?? '#';
|
||||
$replacements['{{current_year}}'] = now()->year;
|
||||
$replacements['{{current_date}}'] = now()->format('F j, Y');
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_scalar($value)) {
|
||||
$replacements["{{{$key}}}"] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return str_replace(
|
||||
array_keys($replacements),
|
||||
array_values($replacements),
|
||||
$content
|
||||
);
|
||||
}
|
||||
|
||||
public function extractTags(string $content): array
|
||||
{
|
||||
preg_match_all('/\{\{([^}]+)\}\}/', $content, $matches);
|
||||
return array_unique($matches[0]);
|
||||
}
|
||||
|
||||
public function validate(string $content, ?Brand $brand = null): array
|
||||
{
|
||||
$usedTags = $this->extractTags($content);
|
||||
$availableTags = array_keys($this->getAvailableTags($brand));
|
||||
|
||||
$invalidTags = array_diff($usedTags, $availableTags);
|
||||
|
||||
return [
|
||||
'valid' => empty($invalidTags),
|
||||
'invalid_tags' => array_values($invalidTags),
|
||||
];
|
||||
}
|
||||
}
|
||||
88
app/Services/Marketing/MjmlService.php
Normal file
88
app/Services/Marketing/MjmlService.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class MjmlService
|
||||
{
|
||||
public function render(string $mjml): string
|
||||
{
|
||||
if (config('services.mjml.api_key')) {
|
||||
return $this->renderViaApi($mjml);
|
||||
}
|
||||
|
||||
return $this->renderLocally($mjml);
|
||||
}
|
||||
|
||||
protected function renderViaApi(string $mjml): string
|
||||
{
|
||||
$response = Http::withBasicAuth(
|
||||
config('services.mjml.app_id'),
|
||||
config('services.mjml.api_key')
|
||||
)->post('https://api.mjml.io/v1/render', [
|
||||
'mjml' => $mjml,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json('html');
|
||||
}
|
||||
|
||||
throw new \Exception('MJML API error: ' . $response->body());
|
||||
}
|
||||
|
||||
protected function renderLocally(string $mjml): string
|
||||
{
|
||||
$tempMjml = tempnam(sys_get_temp_dir(), 'mjml_');
|
||||
file_put_contents($tempMjml, $mjml);
|
||||
|
||||
$tempHtml = tempnam(sys_get_temp_dir(), 'html_');
|
||||
exec("npx mjml {$tempMjml} -o {$tempHtml}", $output, $returnCode);
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
throw new \Exception('MJML rendering failed');
|
||||
}
|
||||
|
||||
$html = file_get_contents($tempHtml);
|
||||
|
||||
unlink($tempMjml);
|
||||
unlink($tempHtml);
|
||||
|
||||
return $this->inlineCss($html);
|
||||
}
|
||||
|
||||
public function htmlToMjml(string $html): string
|
||||
{
|
||||
return <<<MJML
|
||||
<mjml>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
{$html}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
MJML;
|
||||
}
|
||||
|
||||
protected function inlineCss(string $html): string
|
||||
{
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function validate(string $mjml): array
|
||||
{
|
||||
try {
|
||||
$this->render($mjml);
|
||||
return ['valid' => true, 'errors' => []];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'errors' => [$e->getMessage()]
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
228
app/Services/Marketing/TemplateService.php
Normal file
228
app/Services/Marketing/TemplateService.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use App\Models\Marketing\Template;
|
||||
use App\Models\Marketing\TemplateAnalytics;
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TemplateService
|
||||
{
|
||||
public function __construct(
|
||||
protected MjmlService $mjmlService,
|
||||
protected MergeTagService $mergeTagService
|
||||
) {}
|
||||
|
||||
public function create(array $data): Template
|
||||
{
|
||||
if (!empty($data['mjml_content'])) {
|
||||
$data['html_content'] = $this->mjmlService->render($data['mjml_content']);
|
||||
}
|
||||
|
||||
if (!empty($data['html_content'])) {
|
||||
$data['plain_text'] = strip_tags($data['html_content']);
|
||||
}
|
||||
|
||||
if (empty($data['thumbnail']) && !empty($data['html_content'])) {
|
||||
$data['thumbnail'] = $this->generateThumbnail($data['html_content']);
|
||||
}
|
||||
|
||||
$template = Template::create([
|
||||
'business_id' => currentBusiness()->id,
|
||||
'created_by' => auth()->id(),
|
||||
...$data
|
||||
]);
|
||||
|
||||
$template->createVersion('Initial version');
|
||||
$this->initializeAnalytics($template);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
public function update(Template $template, array $data): Template
|
||||
{
|
||||
if ($template->is_system_template) {
|
||||
throw new \Exception('Cannot edit system templates');
|
||||
}
|
||||
|
||||
if (!empty($data['mjml_content'])) {
|
||||
$data['html_content'] = $this->mjmlService->render($data['mjml_content']);
|
||||
}
|
||||
|
||||
if (!empty($data['html_content'])) {
|
||||
$data['plain_text'] = strip_tags($data['html_content']);
|
||||
}
|
||||
|
||||
$data['version'] = $template->version + 1;
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
$template->createVersion($data['change_notes'] ?? 'Updated template');
|
||||
$template->update($data);
|
||||
|
||||
return $template->fresh();
|
||||
}
|
||||
|
||||
public function duplicate(Template $template, string $newName, ?int $brandId = null): Template
|
||||
{
|
||||
$duplicate = $template->duplicate($newName);
|
||||
|
||||
if ($brandId) {
|
||||
$this->addToBrand($duplicate, $brandId);
|
||||
}
|
||||
|
||||
return $duplicate;
|
||||
}
|
||||
|
||||
public function delete(Template $template): bool
|
||||
{
|
||||
if (!$template->canBeDeleted()) {
|
||||
throw new \Exception('Template is in use and cannot be deleted');
|
||||
}
|
||||
|
||||
return $template->delete();
|
||||
}
|
||||
|
||||
public function addToBrand(Template $template, int $brandId): void
|
||||
{
|
||||
$brand = Brand::findOrFail($brandId);
|
||||
|
||||
if ($template->brands()->where('brand_id', $brandId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$template->brands()->attach($brandId, [
|
||||
'added_by' => auth()->id(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeFromBrand(Template $template, int $brandId): void
|
||||
{
|
||||
$template->brands()->detach($brandId);
|
||||
}
|
||||
|
||||
public function toggleFavorite(Template $template, int $brandId): void
|
||||
{
|
||||
$pivot = DB::table('brand_templates')
|
||||
->where('brand_id', $brandId)
|
||||
->where('template_id', $template->id)
|
||||
->first();
|
||||
|
||||
if ($pivot) {
|
||||
DB::table('brand_templates')
|
||||
->where('id', $pivot->id)
|
||||
->update(['is_favorite' => !$pivot->is_favorite]);
|
||||
}
|
||||
}
|
||||
|
||||
public function importFromHtml(string $html, array $metadata = []): Template
|
||||
{
|
||||
$mjml = $this->mjmlService->htmlToMjml($html);
|
||||
$designJson = $this->htmlToGrapesJsDesign($html);
|
||||
|
||||
return $this->create([
|
||||
'name' => $metadata['name'] ?? 'Imported Template',
|
||||
'description' => $metadata['description'] ?? null,
|
||||
'category_id' => $metadata['category_id'] ?? null,
|
||||
'tags' => $metadata['tags'] ?? [],
|
||||
'design_json' => $designJson,
|
||||
'mjml_content' => $mjml,
|
||||
'html_content' => $html,
|
||||
'template_type' => 'email',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportToHtml(Template $template): string
|
||||
{
|
||||
return $template->html_content;
|
||||
}
|
||||
|
||||
public function exportToMjml(Template $template): string
|
||||
{
|
||||
return $template->mjml_content ?? $this->mjmlService->htmlToMjml($template->html_content);
|
||||
}
|
||||
|
||||
public function exportAsZip(Template $template): string
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
$filename = storage_path('app/temp/' . $template->slug . '.zip');
|
||||
|
||||
if ($zip->open($filename, \ZipArchive::CREATE) !== true) {
|
||||
throw new \Exception('Cannot create ZIP file');
|
||||
}
|
||||
|
||||
$zip->addFromString('template.html', $template->html_content);
|
||||
|
||||
if ($template->mjml_content) {
|
||||
$zip->addFromString('template.mjml', $template->mjml_content);
|
||||
}
|
||||
|
||||
$zip->addFromString('design.json', json_encode($template->design_json, JSON_PRETTY_PRINT));
|
||||
|
||||
$metadata = [
|
||||
'name' => $template->name,
|
||||
'description' => $template->description,
|
||||
'category' => $template->category?->name,
|
||||
'tags' => $template->tags,
|
||||
'version' => $template->version,
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
];
|
||||
$zip->addFromString('metadata.json', json_encode($metadata, JSON_PRETTY_PRINT));
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
public function render(Template $template, array $data = [], ?Brand $brand = null): string
|
||||
{
|
||||
return $this->mergeTagService->replace(
|
||||
$template->html_content,
|
||||
$data,
|
||||
currentBusiness(),
|
||||
$brand
|
||||
);
|
||||
}
|
||||
|
||||
protected function initializeAnalytics(Template $template): void
|
||||
{
|
||||
TemplateAnalytics::create([
|
||||
'template_id' => $template->id,
|
||||
'business_id' => $template->business_id,
|
||||
'brand_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function generateThumbnail(string $html): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function htmlToGrapesJsDesign(string $html): array
|
||||
{
|
||||
return [
|
||||
'assets' => [],
|
||||
'styles' => [],
|
||||
'pages' => [
|
||||
[
|
||||
'frames' => [
|
||||
[
|
||||
'component' => [
|
||||
'type' => 'wrapper',
|
||||
'components' => [
|
||||
[
|
||||
'type' => 'text',
|
||||
'content' => $html,
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const content = fs.readFileSync('resources/views/seller/products/edit11.blade.php', 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let depth = 0;
|
||||
const stack = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNum = i + 1;
|
||||
|
||||
// Skip lines that are Alpine.js @error handlers
|
||||
if (line.includes('@error') && line.includes('$event')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for @if (but not in @endif, @error, @enderror)
|
||||
if (/@if\s*\(/.test(line) && !/@endif/.test(line)) {
|
||||
depth++;
|
||||
stack.push({ line: lineNum, type: 'if', content: line.trim().substring(0, 80) });
|
||||
console.log(`${lineNum}: [depth +${depth}] @if`);
|
||||
}
|
||||
|
||||
// Check for @elseif
|
||||
if (/@elseif\s*\(/.test(line)) {
|
||||
console.log(`${lineNum}: [depth =${depth}] @elseif`);
|
||||
}
|
||||
|
||||
// Check for @else (but not @elseif, @endforelse, @enderror)
|
||||
if (/@else\b/.test(line) && !/@elseif/.test(line) && !/@endforelse/.test(line) && !/@enderror/.test(line)) {
|
||||
console.log(`${lineNum}: [depth =${depth}] @else`);
|
||||
}
|
||||
|
||||
// Check for @endif
|
||||
if (/@endif\b/.test(line)) {
|
||||
console.log(`${lineNum}: [depth -${depth}] @endif`);
|
||||
if (depth > 0) {
|
||||
depth--;
|
||||
stack.pop();
|
||||
} else {
|
||||
console.log(`ERROR: Extra @endif at line ${lineNum}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nFinal depth: ${depth}`);
|
||||
if (depth > 0) {
|
||||
console.log(`\nUNBALANCED: Missing ${depth} @endif statement(s)`);
|
||||
console.log('\nUnclosed @if statements:');
|
||||
stack.forEach(item => {
|
||||
console.log(` Line ${item.line}: ${item.content}`);
|
||||
});
|
||||
} else {
|
||||
console.log('\nAll @if/@endif pairs are balanced!');
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
$file = 'C:\Users\Boss Man\Documents\GitHub\hub\resources\views\seller\products\edit11.blade.php';
|
||||
$lines = file($file);
|
||||
|
||||
$stack = [];
|
||||
|
||||
foreach ($lines as $lineNum => $line) {
|
||||
$lineNum++; // 1-indexed
|
||||
|
||||
// Check for @if (but not @endif, @elseif, etc.)
|
||||
if (preg_match('/^\s*@if\(/', $line)) {
|
||||
$stack[] = ['type' => 'if', 'line' => $lineNum];
|
||||
echo "Line $lineNum: OPEN @if (stack depth: ".count($stack).")\n";
|
||||
}
|
||||
// Check for @elseif
|
||||
elseif (preg_match('/^\s*@elseif\(/', $line)) {
|
||||
echo "Line $lineNum: @elseif\n";
|
||||
}
|
||||
// Check for @else
|
||||
elseif (preg_match('/^\s*@else\s*$/', $line)) {
|
||||
echo "Line $lineNum: @else\n";
|
||||
}
|
||||
// Check for @endif
|
||||
elseif (preg_match('/^\s*@endif\s*$/', $line)) {
|
||||
if (empty($stack)) {
|
||||
echo "ERROR Line $lineNum: @endif without matching @if!\n";
|
||||
} else {
|
||||
$opened = array_pop($stack);
|
||||
echo "Line $lineNum: CLOSE @endif (opened at line {$opened['line']}, stack depth: ".count($stack).")\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($stack)) {
|
||||
echo "\nERROR: Unclosed @if directives:\n";
|
||||
foreach ($stack as $item) {
|
||||
echo " Line {$item['line']}: @if never closed\n";
|
||||
}
|
||||
} else {
|
||||
echo "\nAll @if/@endif directives are balanced!\n";
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?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::create('broadcasts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('created_by_user_id')->constrained('users');
|
||||
|
||||
// Basic info
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
|
||||
// Type and channel
|
||||
$table->enum('type', ['immediate', 'scheduled', 'recurring']);
|
||||
$table->enum('channel', ['email', 'sms', 'push', 'multi']);
|
||||
|
||||
// Content
|
||||
$table->unsignedBigInteger('template_id')->nullable(); // Foreign key will be added when marketing_templates table exists
|
||||
$table->string('subject')->nullable(); // For emails
|
||||
$table->text('content')->nullable(); // Can override template
|
||||
|
||||
// Targeting
|
||||
$table->json('audience_ids')->nullable(); // Array of audience IDs
|
||||
$table->json('segment_rules')->nullable(); // Custom segmentation
|
||||
$table->boolean('include_all')->default(false);
|
||||
$table->json('exclude_audience_ids')->nullable();
|
||||
|
||||
// Scheduling
|
||||
$table->timestamp('scheduled_at')->nullable();
|
||||
$table->string('timezone')->default('UTC');
|
||||
$table->json('recurring_pattern')->nullable(); // For recurring broadcasts
|
||||
$table->timestamp('recurring_ends_at')->nullable();
|
||||
|
||||
// Sending
|
||||
$table->enum('status', [
|
||||
'draft',
|
||||
'scheduled',
|
||||
'sending',
|
||||
'sent',
|
||||
'paused',
|
||||
'cancelled',
|
||||
'failed'
|
||||
])->default('draft');
|
||||
|
||||
$table->timestamp('started_sending_at')->nullable();
|
||||
$table->timestamp('finished_sending_at')->nullable();
|
||||
|
||||
// Stats
|
||||
$table->integer('total_recipients')->default(0);
|
||||
$table->integer('total_sent')->default(0);
|
||||
$table->integer('total_delivered')->default(0);
|
||||
$table->integer('total_failed')->default(0);
|
||||
$table->integer('total_opened')->default(0);
|
||||
$table->integer('total_clicked')->default(0);
|
||||
$table->integer('total_unsubscribed')->default(0);
|
||||
|
||||
// Settings
|
||||
$table->boolean('track_opens')->default(true);
|
||||
$table->boolean('track_clicks')->default(true);
|
||||
$table->integer('send_rate_limit')->nullable(); // Sends per minute
|
||||
$table->json('metadata')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['business_id', 'status']);
|
||||
$table->index(['business_id', 'channel']);
|
||||
$table->index('scheduled_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('broadcasts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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::create('broadcast_recipients', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('broadcast_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
// Send status
|
||||
$table->enum('status', [
|
||||
'pending',
|
||||
'queued',
|
||||
'sending',
|
||||
'sent',
|
||||
'delivered',
|
||||
'failed',
|
||||
'bounced',
|
||||
'skipped'
|
||||
])->default('pending');
|
||||
|
||||
// Timestamps
|
||||
$table->timestamp('queued_at')->nullable();
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->timestamp('delivered_at')->nullable();
|
||||
$table->timestamp('failed_at')->nullable();
|
||||
|
||||
// Error tracking
|
||||
$table->text('error_message')->nullable();
|
||||
$table->string('error_code')->nullable();
|
||||
|
||||
// Provider info
|
||||
$table->string('provider_message_id')->nullable();
|
||||
$table->json('provider_response')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['broadcast_id', 'user_id']);
|
||||
$table->index(['broadcast_id', 'status']);
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('broadcast_recipients');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?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::create('broadcast_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('broadcast_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
// Event type
|
||||
$table->enum('event', [
|
||||
'opened',
|
||||
'clicked',
|
||||
'bounced',
|
||||
'complained',
|
||||
'unsubscribed',
|
||||
'delivered',
|
||||
'failed'
|
||||
]);
|
||||
|
||||
// Event details
|
||||
$table->string('link_url')->nullable(); // For click events
|
||||
$table->string('user_agent')->nullable();
|
||||
$table->string('ip_address')->nullable();
|
||||
$table->string('device_type')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
|
||||
$table->timestamp('occurred_at')->useCurrent();
|
||||
|
||||
$table->index(['broadcast_id', 'event']);
|
||||
$table->index(['user_id', 'event']);
|
||||
$table->index('occurred_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('broadcast_events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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::create('template_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('icon')->nullable(); // Lucide icon name
|
||||
$table->string('color')->nullable(); // Hex color
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('sort_order');
|
||||
});
|
||||
|
||||
// Seed default categories
|
||||
DB::table('template_categories')->insert([
|
||||
['name' => 'Welcome Series', 'slug' => 'welcome', 'icon' => 'lucide--hand-wave', 'color' => '#3B82F6', 'sort_order' => 1, 'created_at' => now(), 'updated_at' => now()],
|
||||
['name' => 'Promotional', 'slug' => 'promotional', 'icon' => 'lucide--tag', 'color' => '#EF4444', 'sort_order' => 2, 'created_at' => now(), 'updated_at' => now()],
|
||||
['name' => 'Newsletters', 'slug' => 'newsletters', 'icon' => 'lucide--newspaper', 'color' => '#8B5CF6', 'sort_order' => 3, 'created_at' => now(), 'updated_at' => now()],
|
||||
['name' => 'Transactional', 'slug' => 'transactional', 'icon' => 'lucide--receipt', 'color' => '#10B981', 'sort_order' => 4, 'created_at' => now(), 'updated_at' => now()],
|
||||
['name' => 'Product Launch', 'slug' => 'product-launch', 'icon' => 'lucide--rocket', 'color' => '#F59E0B', 'sort_order' => 5, 'created_at' => now(), 'updated_at' => now()],
|
||||
['name' => 'Abandoned Cart', 'slug' => 'abandoned-cart', 'icon' => 'lucide--shopping-cart', 'color' => '#EC4899', 'sort_order' => 6, 'created_at' => now(), 'updated_at' => now()],
|
||||
['name' => 'Re-engagement', 'slug' => 're-engagement', 'icon' => 'lucide--users', 'color' => '#06B6D4', 'sort_order' => 7, 'created_at' => now(), 'updated_at' => now()],
|
||||
['name' => 'Seasonal', 'slug' => 'seasonal', 'icon' => 'lucide--calendar', 'color' => '#14B8A6', 'sort_order' => 8, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('template_categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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::create('templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('category_id')->nullable()->constrained('template_categories')->nullOnDelete();
|
||||
$table->foreignId('created_by')->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
// Template identity
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('thumbnail')->nullable();
|
||||
|
||||
// Template content
|
||||
$table->json('design_json'); // GrapeJS design
|
||||
$table->longText('mjml_content')->nullable(); // MJML source
|
||||
$table->longText('html_content'); // Rendered HTML
|
||||
$table->text('plain_text')->nullable(); // Plain text version
|
||||
|
||||
// Template metadata
|
||||
$table->boolean('is_system_template')->default(false); // Pre-built templates
|
||||
$table->boolean('is_public')->default(false); // Share with other businesses
|
||||
$table->string('template_type')->default('email'); // email, sms, push
|
||||
$table->json('tags')->nullable(); // ['promotional', 'welcome', etc.]
|
||||
|
||||
// Usage tracking
|
||||
$table->integer('usage_count')->default(0);
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
|
||||
// Version control
|
||||
$table->integer('version')->default(1);
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// Indexes
|
||||
$table->index(['business_id', 'template_type']);
|
||||
$table->index(['business_id', 'category_id']);
|
||||
$table->index('is_system_template');
|
||||
$table->index('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('templates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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::create('template_versions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('template_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('created_by')->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->integer('version_number');
|
||||
$table->string('version_name')->nullable(); // "Summer 2025 Update"
|
||||
$table->text('change_notes')->nullable();
|
||||
|
||||
// Snapshot of template at this version
|
||||
$table->json('design_json');
|
||||
$table->longText('mjml_content')->nullable();
|
||||
$table->longText('html_content');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['template_id', 'version_number']);
|
||||
$table->index(['template_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('template_versions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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::create('template_blocks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('created_by')->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('thumbnail')->nullable();
|
||||
|
||||
$table->string('block_type'); // header, footer, product_grid, cta, etc.
|
||||
$table->json('design_json'); // GrapeJS block data
|
||||
|
||||
$table->integer('usage_count')->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['business_id', 'block_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('template_blocks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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::create('brand_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('brand_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('template_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('added_by')->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->boolean('is_favorite')->default(false);
|
||||
$table->integer('usage_count')->default(0);
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['brand_id', 'template_id']);
|
||||
$table->index('brand_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('brand_templates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?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::create('template_analytics', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('template_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('brand_id')->nullable()->constrained()->nullOnDelete();
|
||||
|
||||
// Usage metrics
|
||||
$table->integer('times_used')->default(0);
|
||||
$table->integer('total_sends')->default(0);
|
||||
|
||||
// Engagement metrics (aggregated from broadcasts)
|
||||
$table->integer('total_opens')->default(0);
|
||||
$table->integer('total_clicks')->default(0);
|
||||
$table->integer('total_bounces')->default(0);
|
||||
$table->integer('total_unsubscribes')->default(0);
|
||||
|
||||
// Calculated rates
|
||||
$table->decimal('avg_open_rate', 5, 2)->default(0);
|
||||
$table->decimal('avg_click_rate', 5, 2)->default(0);
|
||||
$table->decimal('avg_bounce_rate', 5, 2)->default(0);
|
||||
|
||||
// Performance tracking
|
||||
$table->timestamp('first_used_at')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->string('best_subject_line')->nullable();
|
||||
$table->decimal('best_open_rate', 5, 2)->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['template_id', 'business_id', 'brand_id']);
|
||||
$table->index(['business_id', 'avg_open_rate']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('template_analytics');
|
||||
}
|
||||
};
|
||||
@@ -41,58 +41,108 @@
|
||||
}
|
||||
}"
|
||||
@scroll.debounce.150ms="localStorage.setItem('sidebar-scroll-position', $el.scrollTop)">
|
||||
<!-- Brand Context Switcher -->
|
||||
<x-brand-switcher />
|
||||
<!-- View Switcher (Sales/Manufacturing/Compliance) -->
|
||||
<x-view-switcher />
|
||||
|
||||
@php
|
||||
$currentView = session('current_view', 'sales');
|
||||
$isOwner = \App\Helpers\BusinessHelper::isOwnerOrAdmin();
|
||||
@endphp
|
||||
|
||||
<div class="mb-3 space-y-0.5 px-2.5" x-data="{
|
||||
menuDashboard: $persist(true).as('sidebar-menu-dashboard'),
|
||||
menuAnalytics: $persist(true).as('sidebar-menu-analytics'),
|
||||
menuOrders: $persist(false).as('sidebar-menu-orders'),
|
||||
menuInvoices: $persist(false).as('sidebar-menu-invoices'),
|
||||
menuInventory: $persist(true).as('sidebar-menu-inventory'),
|
||||
menuCustomers: $persist(false).as('sidebar-menu-customers'),
|
||||
menuMarketing: $persist(false).as('sidebar-menu-marketing'),
|
||||
menuFleet: $persist(true).as('sidebar-menu-fleet'),
|
||||
menuBusiness: $persist(true).as('sidebar-menu-business')
|
||||
menuProduction: $persist(true).as('sidebar-menu-production'),
|
||||
menuWorkOrders: $persist(false).as('sidebar-menu-work-orders'),
|
||||
menuRawMaterials: $persist(false).as('sidebar-menu-raw-materials'),
|
||||
menuQualityControl: $persist(false).as('sidebar-menu-quality-control'),
|
||||
menuCompany: $persist(true).as('sidebar-menu-company'),
|
||||
menuSettings: $persist(true).as('sidebar-menu-settings')
|
||||
}">
|
||||
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Overview</p>
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuDashboard" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--bar-chart-3] size-4"></span>
|
||||
<span class="grow">Dashboard</span>
|
||||
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
|
||||
</div>
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.dashboard') || request()->routeIs('seller.dashboard') ? 'active' : '' }}" href="{{ $sidebarBusiness ? route('seller.business.dashboard', $sidebarBusiness->slug) : route('seller.dashboard') }}">
|
||||
<span class="grow">Overview</span>
|
||||
</a>
|
||||
@if($sidebarBusiness)
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.analytics.*') ? 'active' : '' }}" href="{{ route('seller.business.analytics.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Analytics</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Reports</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
@else
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Analytics</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Reports</span>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<!-- Dashboard - single item (shown in all views) -->
|
||||
@if($sidebarBusiness)
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.dashboard') || request()->routeIs('seller.dashboard') ? 'active' : '' }}" href="{{ $sidebarBusiness ? route('seller.business.dashboard', $sidebarBusiness->slug) : route('seller.dashboard') }}">
|
||||
<span class="icon-[lucide--bar-chart-3] size-4"></span>
|
||||
<span class="grow">Dashboard</span>
|
||||
</a>
|
||||
@else
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.dashboard') || request()->routeIs('seller.dashboard') ? 'active' : '' }}" href="{{ route('seller.dashboard') }}">
|
||||
<span class="icon-[lucide--bar-chart-3] size-4"></span>
|
||||
<span class="grow">Dashboard</span>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if($isOwner || $currentView === 'sales')
|
||||
<!-- SALES VIEW NAVIGATION -->
|
||||
|
||||
<!-- Analytics - parent with subsections -->
|
||||
{{-- TODO: Re-enable permission check when permission system is implemented --}}
|
||||
@if($sidebarBusiness)
|
||||
{{-- @if($sidebarBusiness && hasBusinessPermission('analytics.overview')) --}}
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuAnalytics" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--line-chart] size-4"></span>
|
||||
<span class="grow">Analytics</span>
|
||||
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
|
||||
</div>
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
{{-- TODO: Re-enable permissions when permission system is implemented --}}
|
||||
{{-- @if(hasBusinessPermission('analytics.overview')) --}}
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.analytics.index') ? 'active' : '' }}" href="{{ route('seller.business.analytics.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Overview</span>
|
||||
</a>
|
||||
{{-- @endif --}}
|
||||
|
||||
{{-- @if(hasBusinessPermission('analytics.products')) --}}
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.analytics.products*') ? 'active' : '' }}" href="{{ route('seller.business.analytics.products', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Products</span>
|
||||
</a>
|
||||
{{-- @endif --}}
|
||||
|
||||
|
||||
{{-- @if(hasBusinessPermission('analytics.sales')) --}}
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.analytics.sales*') ? 'active' : '' }}" href="{{ route('seller.business.analytics.sales', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Sales</span>
|
||||
</a>
|
||||
{{-- @endif --}}
|
||||
|
||||
{{-- @if(hasBusinessPermission('analytics.buyers')) --}}
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.analytics.buyers*') ? 'active' : '' }}" href="{{ route('seller.business.analytics.buyers', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Buyers</span>
|
||||
</a>
|
||||
{{-- @endif --}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Ecommerce</p>
|
||||
<div class="group collapse">
|
||||
@endif
|
||||
|
||||
<!-- Reports - future section -->
|
||||
@if($sidebarBusiness)
|
||||
<a class="menu-item" href="#">
|
||||
<span class="icon-[lucide--file-text] size-4"></span>
|
||||
<span class="grow">Reports</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if($isOwner || $currentView === 'sales')
|
||||
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Ecommerce</p>
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
@@ -220,39 +270,191 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Operations</p>
|
||||
<!-- Marketing Section -->
|
||||
@if($sidebarBusiness)
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuFleet" />
|
||||
x-model="menuMarketing" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--truck] size-4"></span>
|
||||
<span class="grow">Fleet Management</span>
|
||||
<span class="icon-[lucide--mail] size-4"></span>
|
||||
<span class="grow">Marketing</span>
|
||||
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
|
||||
</div>
|
||||
@if($sidebarBusiness)
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.fleet.drivers.*') ? 'active' : '' }}" href="{{ route('seller.business.fleet.drivers.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Drivers</span>
|
||||
<!-- Email Marketing -->
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.marketing.templates.*') ? 'active' : '' }}" href="{{ route('seller.business.marketing.templates.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Email Templates</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.fleet.vehicles.*') ? 'active' : '' }}" href="{{ route('seller.business.fleet.vehicles.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Vehicles</span>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.marketing.broadcasts.*') ? 'active' : '' }}" href="{{ route('seller.business.marketing.broadcasts.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Email Campaigns</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Performance</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
|
||||
<!-- SMS (Future) -->
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">SMS Templates</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">SMS Campaigns</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
|
||||
<!-- Social (Future) -->
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Social Posts</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
|
||||
<!-- Audiences & Automations (Future) -->
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Audiences</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Automations</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 px-3 py-2">
|
||||
<p class="text-xs text-base-content/60">Complete your business profile to manage fleet</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($isOwner || $currentView === 'manufacturing')
|
||||
<!-- MANUFACTURING VIEW NAVIGATION -->
|
||||
|
||||
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Production</p>
|
||||
|
||||
<!-- Processing -->
|
||||
<a class="menu-item" href="#">
|
||||
<span class="icon-[lucide--cog] size-4"></span>
|
||||
<span class="grow">Processing</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
|
||||
<!-- Packaging -->
|
||||
<a class="menu-item" href="#">
|
||||
<span class="icon-[lucide--package-open] size-4"></span>
|
||||
<span class="grow">Packaging</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
|
||||
<!-- Delivery -->
|
||||
<a class="menu-item" href="#">
|
||||
<span class="icon-[lucide--truck] size-4"></span>
|
||||
<span class="grow">Delivery</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
|
||||
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Operations</p>
|
||||
|
||||
<!-- Work Orders -->
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuWorkOrders" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--clipboard-list] size-4"></span>
|
||||
<span class="grow">Work Orders</span>
|
||||
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
|
||||
</div>
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">All Orders</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Processing</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Packaging</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Completed</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw Materials -->
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuRawMaterials" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--package-2] size-4"></span>
|
||||
<span class="grow">Raw Materials</span>
|
||||
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
|
||||
</div>
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Inventory</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Suppliers</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Purchase Orders</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quality Control -->
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuQualityControl" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--shield-check] size-4"></span>
|
||||
<span class="grow">Quality Control</span>
|
||||
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
|
||||
</div>
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Inspections</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Test Results</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Compliance Reports</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Business</p>
|
||||
@if(auth()->user()?->hasRole('super-admin'))
|
||||
@@ -262,82 +464,60 @@
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="group collapse">
|
||||
<!-- Business Section -->
|
||||
<div class="group collapse overflow-visible">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuBusiness" />
|
||||
x-model="menuCompany" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--building-2] size-4"></span>
|
||||
<span class="grow">Company</span>
|
||||
<span class="grow">Business</span>
|
||||
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
|
||||
</div>
|
||||
@if($sidebarBusiness)
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
<div class="collapse-content ms-6.5 !p-0 overflow-visible">
|
||||
<div class="mt-0.5 space-y-0.5 pb-1">
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.company-information') ? 'active' : '' }}" href="{{ route('seller.business.settings.company-information', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Company Information</span>
|
||||
<span class="grow">Business Information</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.users') ? 'active' : '' }}" href="{{ route('seller.business.settings.users', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Users</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.orders') ? 'active' : '' }}" href="{{ route('seller.business.settings.orders', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Orders</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.brands') ? 'active' : '' }}" href="{{ route('seller.business.settings.brands', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Brands</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.payments') ? 'active' : '' }}" href="{{ route('seller.business.settings.payments', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Payments</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.invoices') ? 'active' : '' }}" href="{{ route('seller.business.settings.invoices', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Invoices</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.manage-licenses') ? 'active' : '' }}" href="{{ route('seller.business.settings.manage-licenses', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Manage Licenses</span>
|
||||
</a>
|
||||
|
||||
<!-- Finance subsection -->
|
||||
<p class="text-xs font-semibold text-base-content/40 px-2.5 pt-2 pb-1">Finance</p>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.plans-and-billing') ? 'active' : '' }}" href="{{ route('seller.business.settings.plans-and-billing', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Plans and Billing</span>
|
||||
<span class="grow">Subscriptions & Billing</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.notifications') ? 'active' : '' }}" href="{{ route('seller.business.settings.notifications', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Notifications</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.reports') ? 'active' : '' }}" href="{{ route('seller.business.settings.reports', $sidebarBusiness->slug) }}">
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Reports</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
<div class="collapse-content ms-6.5 !p-0 overflow-visible">
|
||||
<div class="mt-0.5 space-y-0.5 pb-1">
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Company Information</span>
|
||||
<span class="grow">Business Information</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Users</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Orders</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Brands</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Payments</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Invoices</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Manage Licenses</span>
|
||||
</a>
|
||||
|
||||
<!-- Finance subsection -->
|
||||
<p class="text-xs font-semibold text-base-content/40 px-2.5 pt-2 pb-1">Finance</p>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Plans and Billing</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Notifications</span>
|
||||
<span class="grow">Subscriptions & Billing</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Reports</span>
|
||||
@@ -346,7 +526,139 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Settings Section -->
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuSettings" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--settings] size-4"></span>
|
||||
<span class="grow">Settings</span>
|
||||
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
|
||||
</div>
|
||||
@if($sidebarBusiness)
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
@if($isOwner)
|
||||
<!-- Owner sees all settings with department separators -->
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.notifications') ? 'active' : '' }}" href="{{ route('seller.business.settings.notifications', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Notifications</span>
|
||||
</a>
|
||||
|
||||
<!-- Sales Department Settings -->
|
||||
<p class="text-xs font-semibold text-base-content/40 px-2.5 pt-2 pb-1">— Sales —</p>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.categories.*') ? 'active' : '' }}" href="{{ route('seller.business.settings.categories.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Categories</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.brands') ? 'active' : '' }}" href="{{ route('seller.business.settings.brands', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Brands</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.orders') ? 'active' : '' }}" href="{{ route('seller.business.settings.orders', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Orders</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.invoices') ? 'active' : '' }}" href="{{ route('seller.business.settings.invoices', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Invoices</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.reports') && request()->get('department') === 'sales' ? 'active' : '' }}" href="#">
|
||||
<span class="grow">Reports</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
|
||||
<!-- Manufacturing Department Settings -->
|
||||
@if($sidebarBusiness->has_manufacturing)
|
||||
<p class="text-xs font-semibold text-base-content/40 px-2.5 pt-2 pb-1">— Manufacturing —</p>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Component Categories</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Work Orders</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.fleet.drivers.*') ? 'active' : '' }}" href="{{ route('seller.business.fleet.drivers.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Fleet - Drivers</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.fleet.vehicles.*') ? 'active' : '' }}" href="{{ route('seller.business.fleet.vehicles.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Fleet - Vehicles</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Reports</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
<!-- Department admin sees only their section based on current view -->
|
||||
@if($currentView === 'sales')
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.categories.*') ? 'active' : '' }}" href="{{ route('seller.business.settings.categories.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Categories</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.brands') ? 'active' : '' }}" href="{{ route('seller.business.settings.brands', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Brands</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.orders') ? 'active' : '' }}" href="{{ route('seller.business.settings.orders', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Orders</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.settings.invoices') ? 'active' : '' }}" href="{{ route('seller.business.settings.invoices', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Invoices</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Reports</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
@elseif($currentView === 'manufacturing' && $sidebarBusiness->has_manufacturing)
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Component Categories</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Work Orders</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.fleet.drivers.*') ? 'active' : '' }}" href="{{ route('seller.business.fleet.drivers.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Fleet - Drivers</span>
|
||||
</a>
|
||||
<a class="menu-item {{ request()->routeIs('seller.business.fleet.vehicles.*') ? 'active' : '' }}" href="{{ route('seller.business.fleet.vehicles.index', $sidebarBusiness->slug) }}">
|
||||
<span class="grow">Fleet - Vehicles</span>
|
||||
</a>
|
||||
<a class="menu-item" href="#">
|
||||
<span class="grow">Reports</span>
|
||||
<span class="badge badge-sm badge-primary">Soon</span>
|
||||
</a>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="collapse-content ms-6.5 !p-0">
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Notifications</span>
|
||||
</a>
|
||||
<p class="text-xs font-semibold text-base-content/40 px-2.5 pt-2 pb-1">— Sales —</p>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Categories</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Brands</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Orders</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Invoices</span>
|
||||
</a>
|
||||
<a class="menu-item opacity-50 cursor-not-allowed" title="Complete business profile first">
|
||||
<span class="grow">Reports</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="from-base-100/60 pointer-events-none absolute start-0 end-0 bottom-0 h-7 bg-gradient-to-t to-transparent"></div>
|
||||
@@ -357,14 +669,14 @@
|
||||
<!-- Version Info Section -->
|
||||
<div class="mx-2 mb-3 px-3 text-xs text-center text-base-content/50">
|
||||
<p class="mb-0.5">{{ config('version.company.name') }} hub</p>
|
||||
<p class="font-mono mb-0.5">
|
||||
<p class="mb-0.5" style="font-family: 'Courier New', monospace;">
|
||||
@if($appVersion === 'dev')
|
||||
<span class="text-yellow-500 font-semibold">DEV</span> sha-{{ $appCommit }}
|
||||
@else
|
||||
v{{ $appVersion }} (sha-{{ $appCommit }})
|
||||
@endif
|
||||
</p>
|
||||
<p>© {{ date('Y') }} {{ config('version.company.name') }}.com, {{ config('version.company.suffix') }}</p>
|
||||
<p>© {{ date('Y') }} Made with <span class="text-error">♥</span> <a href="https://creationshop.io" target="_blank" rel="noopener noreferrer" class="link link-hover text-xs">Creationshop</a></p>
|
||||
</div>
|
||||
|
||||
<div class="dropdown dropdown-top dropdown-end w-full">
|
||||
|
||||
46
resources/views/seller/dashboard-v2/compliance.blade.php
Normal file
46
resources/views/seller/dashboard-v2/compliance.blade.php
Normal file
@@ -0,0 +1,46 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto p-6">
|
||||
<!-- Toggle back to old dashboard -->
|
||||
<div class="alert alert-info mb-6">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span>🛡️ New Dashboard v2 (Beta) - Compliance View</span>
|
||||
<a href="{{ route('seller.business.dashboard', $business->slug) }}"
|
||||
class="btn btn-ghost btn-sm">
|
||||
← Back to Classic Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6">Compliance Dashboard v2</h1>
|
||||
<p class="text-base-content/70 mb-8">Regulatory compliance, documentation, and audit trail.</p>
|
||||
|
||||
<!-- Placeholder content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Compliance Status</h2>
|
||||
<p>Current regulatory compliance overview</p>
|
||||
<span class="badge badge-primary">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Documentation</h2>
|
||||
<p>Required documents and certifications</p>
|
||||
<span class="badge badge-primary">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Audit Trail</h2>
|
||||
<p>Historical compliance and tracking</p>
|
||||
<span class="badge badge-primary">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
46
resources/views/seller/dashboard-v2/manufacturing.blade.php
Normal file
46
resources/views/seller/dashboard-v2/manufacturing.blade.php
Normal file
@@ -0,0 +1,46 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto p-6">
|
||||
<!-- Toggle back to old dashboard -->
|
||||
<div class="alert alert-info mb-6">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span>🏭 New Dashboard v2 (Beta) - Manufacturing View</span>
|
||||
<a href="{{ route('seller.business.dashboard', $business->slug) }}"
|
||||
class="btn btn-ghost btn-sm">
|
||||
← Back to Classic Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6">Manufacturing Dashboard v2</h1>
|
||||
<p class="text-base-content/70 mb-8">Production overview, batch tracking, and quality control metrics.</p>
|
||||
|
||||
<!-- Placeholder content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Production Overview</h2>
|
||||
<p>Current production status and metrics</p>
|
||||
<span class="badge badge-primary">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Batch Tracking</h2>
|
||||
<p>Active batches and processing stages</p>
|
||||
<span class="badge badge-primary">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Quality Control</h2>
|
||||
<p>Testing results and compliance status</p>
|
||||
<span class="badge badge-primary">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
46
resources/views/seller/dashboard-v2/sales.blade.php
Normal file
46
resources/views/seller/dashboard-v2/sales.blade.php
Normal file
@@ -0,0 +1,46 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto p-6">
|
||||
<!-- Toggle back to old dashboard -->
|
||||
<div class="alert alert-info mb-6">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span>📊 New Dashboard v2 (Beta) - Sales View</span>
|
||||
<a href="{{ route('seller.business.dashboard', $business->slug) }}"
|
||||
class="btn btn-ghost btn-sm">
|
||||
← Back to Classic Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold mb-6">Sales Dashboard v2</h1>
|
||||
<p class="text-base-content/70 mb-8">Comprehensive dashboard with action center, performance metrics, and more.</p>
|
||||
|
||||
<!-- Placeholder content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Action Center</h2>
|
||||
<p>View pending tasks and actions requiring attention</p>
|
||||
<span class="badge badge-primary">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Performance Metrics</h2>
|
||||
<p>Real-time business performance indicators</p>
|
||||
<span class="badge badge-primary">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Quick Actions</h2>
|
||||
<p>Frequently used shortcuts and tools</p>
|
||||
<span class="badge badge-primary">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -13,6 +13,21 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<!-- Dashboard v2 Beta Banner -->
|
||||
<div class="alert alert-success mb-6">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<span class="icon-[lucide--sparkles] size-6 flex-shrink-0"></span>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold">Try Our New Dashboard v2 (Beta)</h3>
|
||||
<p class="text-sm mt-1">Comprehensive dashboard with action center, performance metrics, and more.</p>
|
||||
</div>
|
||||
<a href="{{ route('seller.business.dashboard.v2', $business->slug) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
Switch to New Dashboard →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onboarding Banner -->
|
||||
@if($needsOnboarding && !$isPending && !$isRejected)
|
||||
<div class="alert alert-warning mb-6 shadow-lg">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
@extends('layouts.seller')
|
||||
|
||||
@section('title', 'Analytics - ' . $broadcast->name)
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">{{ $broadcast->name }} - Analytics</h1>
|
||||
<p class="text-base-content/70">Detailed performance metrics</p>
|
||||
</div>
|
||||
|
||||
{{-- Key Metrics --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat bg-base-100 shadow rounded-lg">
|
||||
<div class="stat-title">Total Sent</div>
|
||||
<div class="stat-value text-info">{{ number_format($stats['total_sent']) }}</div>
|
||||
<div class="stat-desc">{{ number_format($stats['total_recipients']) }} recipients</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 shadow rounded-lg">
|
||||
<div class="stat-title">Delivery Rate</div>
|
||||
<div class="stat-value text-success">{{ $stats['delivery_rate'] }}%</div>
|
||||
<div class="stat-desc">{{ number_format($stats['total_delivered']) }} delivered</div>
|
||||
</div>
|
||||
|
||||
@if($broadcast->channel === 'email')
|
||||
<div class="stat bg-base-100 shadow rounded-lg">
|
||||
<div class="stat-title">Open Rate</div>
|
||||
<div class="stat-value">{{ $stats['open_rate'] }}%</div>
|
||||
<div class="stat-desc">{{ number_format($stats['total_opened']) }} opens</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 shadow rounded-lg">
|
||||
<div class="stat-title">Click Rate</div>
|
||||
<div class="stat-value">{{ $stats['click_rate'] }}%</div>
|
||||
<div class="stat-desc">{{ number_format($stats['total_clicked']) }} clicks</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Status Breakdown --}}
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recipient Status</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
@foreach($stats['status_breakdown'] as $status => $count)
|
||||
<div class="stat bg-base-200 rounded-lg p-4">
|
||||
<div class="stat-title text-xs">{{ ucfirst($status) }}</div>
|
||||
<div class="stat-value text-lg">{{ number_format($count) }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Top Clicked Links --}}
|
||||
@if($topLinks->isNotEmpty())
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Top Clicked Links</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URL</th>
|
||||
<th>Clicks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($topLinks as $link)
|
||||
<tr>
|
||||
<td class="text-sm">{{ Str::limit($link->link_url, 50) }}</td>
|
||||
<td><span class="badge badge-primary">{{ $link->count }}</span></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<a href="{{ route('seller.marketing.broadcasts.show', $broadcast) }}" class="btn btn-ghost">Back to Broadcast</a>
|
||||
</div>
|
||||
@endsection
|
||||
299
resources/views/seller/marketing/broadcasts/create.blade.php
Normal file
299
resources/views/seller/marketing/broadcasts/create.blade.php
Normal file
@@ -0,0 +1,299 @@
|
||||
@extends('layouts.seller')
|
||||
|
||||
@section('title', 'Create Broadcast')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6 max-w-5xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">Create Broadcast</h1>
|
||||
<p class="text-base-content/70">Send a message to your audience</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('seller.marketing.broadcasts.store') }}" x-data="broadcastForm()" class="space-y-6">
|
||||
@csrf
|
||||
|
||||
{{-- Basic Info --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Basic Information</h2>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Broadcast Name *</span></label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value="{{ old('name') }}"
|
||||
class="input input-bordered @error('name') input-error @enderror"
|
||||
placeholder="Black Friday Sale Announcement"
|
||||
required
|
||||
/>
|
||||
@error('name')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Description</span></label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="2"
|
||||
class="textarea textarea-bordered"
|
||||
placeholder="Internal description..."
|
||||
>{{ old('description') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Channel & Type --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Delivery Settings</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Channel *</span></label>
|
||||
<select name="channel" x-model="channel" class="select select-bordered" required>
|
||||
<option value="">Select channel...</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="push">Push Notification</option>
|
||||
<option value="multi">Multi-Channel</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Send Type *</span></label>
|
||||
<select name="type" x-model="type" class="select select-bordered" required>
|
||||
<option value="immediate">Send Immediately</option>
|
||||
<option value="scheduled">Schedule for Later</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Scheduling --}}
|
||||
<div x-show="type === 'scheduled'" class="form-control mt-4">
|
||||
<label class="label"><span class="label-text font-semibold">Schedule Date & Time *</span></label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="scheduled_at"
|
||||
value="{{ old('scheduled_at') }}"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Audience Selection --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Select Audience</h2>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="radio"
|
||||
name="targeting"
|
||||
value="all"
|
||||
class="radio radio-primary"
|
||||
x-model="targeting"
|
||||
checked
|
||||
/>
|
||||
<div>
|
||||
<div class="font-bold">All Buyers</div>
|
||||
<div class="text-sm text-base-content/70">Send to all buyers in your database</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="radio"
|
||||
name="targeting"
|
||||
value="audiences"
|
||||
class="radio radio-primary"
|
||||
x-model="targeting"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-bold">Specific Audiences</div>
|
||||
<div class="text-sm text-base-content/70">Choose from your saved audience segments</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div x-show="targeting === 'audiences'" class="mt-4 pl-8">
|
||||
<input type="hidden" name="include_all" value="0">
|
||||
<div class="space-y-2">
|
||||
@forelse($audiences as $audience)
|
||||
<label class="label cursor-pointer justify-start gap-4 p-3 bg-base-200 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="audience_ids[]"
|
||||
value="{{ $audience->id }}"
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold">{{ $audience->name }}</div>
|
||||
<div class="text-sm text-base-content/70">
|
||||
{{ number_format($audience->member_count) }} members
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@empty
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
No audiences created yet.
|
||||
<a href="{{ route('seller.marketing.audiences.create') }}" class="link link-primary">Create one</a>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="include_all" :value="targeting === 'all' ? '1' : '0'">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Content Selection --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Message Content</h2>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="radio"
|
||||
name="content_type"
|
||||
value="template"
|
||||
class="radio radio-primary"
|
||||
x-model="contentType"
|
||||
checked
|
||||
/>
|
||||
<div>
|
||||
<div class="font-bold">Use Template</div>
|
||||
<div class="text-sm text-base-content/70">Select from your saved templates</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div x-show="contentType === 'template'" class="mt-4 pl-8">
|
||||
<select name="template_id" class="select select-bordered w-full">
|
||||
<option value="">Select template...</option>
|
||||
@foreach($templates as $template)
|
||||
<option value="{{ $template->id }}">{{ $template->name }} ({{ ucfirst($template->type) }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="radio"
|
||||
name="content_type"
|
||||
value="custom"
|
||||
class="radio radio-primary"
|
||||
x-model="contentType"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-bold">Custom Content</div>
|
||||
<div class="text-sm text-base-content/70">Write your message now</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div x-show="contentType === 'custom'" class="space-y-4 mt-4 pl-8">
|
||||
<div x-show="channel === 'email'" class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Subject *</span></label>
|
||||
<input
|
||||
type="text"
|
||||
name="subject"
|
||||
class="input input-bordered"
|
||||
placeholder="Email subject line..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Message *</span></label>
|
||||
<textarea
|
||||
name="content"
|
||||
rows="6"
|
||||
class="textarea textarea-bordered"
|
||||
placeholder="Your message content..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Advanced Settings --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Advanced Settings</h2>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="track_opens"
|
||||
value="1"
|
||||
class="checkbox checkbox-primary"
|
||||
checked
|
||||
/>
|
||||
<span class="label-text">Track Opens</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="track_clicks"
|
||||
value="1"
|
||||
class="checkbox checkbox-primary"
|
||||
checked
|
||||
/>
|
||||
<span class="label-text">Track Clicks</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Send Rate Limit (messages/minute)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
name="send_rate_limit"
|
||||
value="100"
|
||||
min="1"
|
||||
max="1000"
|
||||
class="input input-bordered w-48"
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Controls sending speed to avoid rate limits</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Actions --}}
|
||||
<div class="flex justify-end gap-4">
|
||||
<a href="{{ route('seller.marketing.broadcasts.index') }}" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg">Create Broadcast</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('broadcastForm', () => ({
|
||||
channel: 'email',
|
||||
type: 'immediate',
|
||||
targeting: 'all',
|
||||
contentType: 'template'
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
261
resources/views/seller/marketing/broadcasts/index.blade.php
Normal file
261
resources/views/seller/marketing/broadcasts/index.blade.php
Normal file
@@ -0,0 +1,261 @@
|
||||
@extends('layouts.seller')
|
||||
|
||||
@section('title', 'Broadcasts')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Broadcasts</h1>
|
||||
<p class="text-base-content/70">Send mass messages to your audiences</p>
|
||||
</div>
|
||||
|
||||
<a href="{{ route('seller.marketing.broadcasts.create') }}" class="btn btn-primary gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Create Broadcast
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success mb-6">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Filters --}}
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" class="flex flex-wrap gap-4">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value="{{ request('search') }}"
|
||||
placeholder="Search broadcasts..."
|
||||
class="input input-bordered flex-1 min-w-[200px]"
|
||||
/>
|
||||
|
||||
<select name="status" class="select select-bordered w-40">
|
||||
<option value="">All Status</option>
|
||||
<option value="draft" {{ request('status') === 'draft' ? 'selected' : '' }}>Draft</option>
|
||||
<option value="scheduled" {{ request('status') === 'scheduled' ? 'selected' : '' }}>Scheduled</option>
|
||||
<option value="sending" {{ request('status') === 'sending' ? 'selected' : '' }}>Sending</option>
|
||||
<option value="sent" {{ request('status') === 'sent' ? 'selected' : '' }}>Sent</option>
|
||||
<option value="paused" {{ request('status') === 'paused' ? 'selected' : '' }}>Paused</option>
|
||||
<option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }}>Cancelled</option>
|
||||
</select>
|
||||
|
||||
<select name="channel" class="select select-bordered w-40">
|
||||
<option value="">All Channels</option>
|
||||
<option value="email" {{ request('channel') === 'email' ? 'selected' : '' }}>Email</option>
|
||||
<option value="sms" {{ request('channel') === 'sms' ? 'selected' : '' }}>SMS</option>
|
||||
<option value="push" {{ request('channel') === 'push' ? 'selected' : '' }}>Push</option>
|
||||
<option value="multi" {{ request('channel') === 'multi' ? 'selected' : '' }}>Multi-Channel</option>
|
||||
</select>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Filter</button>
|
||||
|
||||
@if(request()->hasAny(['search', 'status', 'channel']))
|
||||
<a href="{{ route('seller.marketing.broadcasts.index') }}" class="btn btn-ghost">Clear</a>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Broadcasts List --}}
|
||||
<div class="grid gap-6">
|
||||
@forelse($broadcasts as $broadcast)
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition border-l-4 {{
|
||||
$broadcast->status === 'sent' ? 'border-success' :
|
||||
($broadcast->status === 'sending' ? 'border-info' :
|
||||
($broadcast->status === 'failed' ? 'border-error' : 'border-base-300'))
|
||||
}}">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h2 class="card-title text-2xl">{{ $broadcast->name }}</h2>
|
||||
|
||||
{{-- Status Badge --}}
|
||||
@php
|
||||
$statusColors = [
|
||||
'draft' => 'badge-ghost',
|
||||
'scheduled' => 'badge-info',
|
||||
'sending' => 'badge-warning',
|
||||
'sent' => 'badge-success',
|
||||
'paused' => 'badge-warning',
|
||||
'cancelled' => 'badge-error',
|
||||
'failed' => 'badge-error',
|
||||
];
|
||||
@endphp
|
||||
<span class="badge badge-lg {{ $statusColors[$broadcast->status] ?? 'badge-ghost' }}">
|
||||
{{ ucfirst($broadcast->status) }}
|
||||
</span>
|
||||
|
||||
{{-- Channel Badge --}}
|
||||
<span class="badge badge-outline">{{ ucfirst($broadcast->channel) }}</span>
|
||||
</div>
|
||||
|
||||
@if($broadcast->description)
|
||||
<p class="text-base-content/70 mb-3">{{ $broadcast->description }}</p>
|
||||
@endif
|
||||
|
||||
{{-- Stats Grid --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4">
|
||||
<div class="stat p-3 bg-base-200 rounded-lg">
|
||||
<div class="stat-title text-xs">Recipients</div>
|
||||
<div class="stat-value text-lg">{{ number_format($broadcast->total_recipients) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat p-3 bg-base-200 rounded-lg">
|
||||
<div class="stat-title text-xs">Sent</div>
|
||||
<div class="stat-value text-lg text-info">{{ number_format($broadcast->total_sent) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat p-3 bg-base-200 rounded-lg">
|
||||
<div class="stat-title text-xs">Delivered</div>
|
||||
<div class="stat-value text-lg text-success">{{ number_format($broadcast->total_delivered) }}</div>
|
||||
</div>
|
||||
|
||||
@if($broadcast->channel === 'email')
|
||||
<div class="stat p-3 bg-base-200 rounded-lg">
|
||||
<div class="stat-title text-xs">Opens</div>
|
||||
<div class="stat-value text-lg">{{ number_format($broadcast->total_opened) }}</div>
|
||||
<div class="stat-desc">{{ $broadcast->getOpenRate() }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="stat p-3 bg-base-200 rounded-lg">
|
||||
<div class="stat-title text-xs">Clicks</div>
|
||||
<div class="stat-value text-lg">{{ number_format($broadcast->total_clicked) }}</div>
|
||||
<div class="stat-desc">{{ $broadcast->getClickRate() }}%</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($broadcast->total_failed > 0)
|
||||
<div class="stat p-3 bg-error/10 rounded-lg">
|
||||
<div class="stat-title text-xs">Failed</div>
|
||||
<div class="stat-value text-lg text-error">{{ number_format($broadcast->total_failed) }}</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Progress Bar (if sending) --}}
|
||||
@if($broadcast->status === 'sending')
|
||||
@php
|
||||
$progress = $broadcast->total_recipients > 0
|
||||
? ($broadcast->total_sent / $broadcast->total_recipients) * 100
|
||||
: 0;
|
||||
@endphp
|
||||
<div class="mt-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span>Sending progress</span>
|
||||
<span>{{ round($progress, 1) }}%</span>
|
||||
</div>
|
||||
<progress class="progress progress-info w-full" value="{{ $progress }}" max="100"></progress>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Meta Info --}}
|
||||
<div class="flex flex-wrap gap-4 mt-4 text-sm text-base-content/70">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
{{ $broadcast->createdBy->name }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{{ $broadcast->created_at->format('M j, Y g:i A') }}
|
||||
</div>
|
||||
|
||||
@if($broadcast->scheduled_at)
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Scheduled: {{ $broadcast->scheduled_at->format('M j g:i A') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Actions Dropdown --}}
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"></path>
|
||||
</svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52 z-10">
|
||||
<li><a href="{{ route('seller.marketing.broadcasts.show', $broadcast) }}">View Details</a></li>
|
||||
<li><a href="{{ route('seller.marketing.broadcasts.analytics', $broadcast) }}">Analytics</a></li>
|
||||
|
||||
@if($broadcast->isDraft())
|
||||
<li><a href="{{ route('seller.marketing.broadcasts.edit', $broadcast) }}">Edit</a></li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('seller.marketing.broadcasts.send', $broadcast) }}">
|
||||
@csrf
|
||||
<button type="submit">Send Now</button>
|
||||
</form>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if($broadcast->isSending())
|
||||
<li>
|
||||
<form method="POST" action="{{ route('seller.marketing.broadcasts.pause', $broadcast) }}">
|
||||
@csrf
|
||||
<button type="submit">Pause</button>
|
||||
</form>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if($broadcast->status === 'paused')
|
||||
<li>
|
||||
<form method="POST" action="{{ route('seller.marketing.broadcasts.resume', $broadcast) }}">
|
||||
@csrf
|
||||
<button type="submit">Resume</button>
|
||||
</form>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if($broadcast->canBeCancelled())
|
||||
<li>
|
||||
<form method="POST" action="{{ route('seller.marketing.broadcasts.cancel', $broadcast) }}" onsubmit="return confirm('Cancel this broadcast?')">
|
||||
@csrf
|
||||
<button type="submit" class="text-error">Cancel</button>
|
||||
</form>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
<li>
|
||||
<form method="POST" action="{{ route('seller.marketing.broadcasts.duplicate', $broadcast) }}">
|
||||
@csrf
|
||||
<button type="submit">Duplicate</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body text-center py-16">
|
||||
<svg class="w-20 h-20 mx-auto text-base-content/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
|
||||
</svg>
|
||||
<h3 class="text-2xl font-bold mt-6">No broadcasts yet</h3>
|
||||
<p class="text-base-content/70 mt-2">Create your first broadcast to reach your audience</p>
|
||||
<a href="{{ route('seller.marketing.broadcasts.create') }}" class="btn btn-primary btn-lg mt-6">
|
||||
Create First Broadcast
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{ $broadcasts->links() }}
|
||||
</div>
|
||||
@endsection
|
||||
150
resources/views/seller/marketing/broadcasts/show.blade.php
Normal file
150
resources/views/seller/marketing/broadcasts/show.blade.php
Normal file
@@ -0,0 +1,150 @@
|
||||
@extends('layouts.seller')
|
||||
|
||||
@section('title', $broadcast->name)
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-3xl font-bold">{{ $broadcast->name }}</h1>
|
||||
<span class="badge badge-lg badge-primary">{{ ucfirst($broadcast->status) }}</span>
|
||||
<span class="badge badge-outline">{{ ucfirst($broadcast->channel) }}</span>
|
||||
</div>
|
||||
@if($broadcast->description)
|
||||
<p class="text-base-content/70 mt-2">{{ $broadcast->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
@if($broadcast->isDraft())
|
||||
<form method="POST" action="{{ route('seller.marketing.broadcasts.send', $broadcast) }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary">Send Now</button>
|
||||
</form>
|
||||
<a href="{{ route('seller.marketing.broadcasts.edit', $broadcast) }}" class="btn btn-outline">Edit</a>
|
||||
@endif
|
||||
|
||||
@if($broadcast->isSending())
|
||||
<form method="POST" action="{{ route('seller.marketing.broadcasts.pause', $broadcast) }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-warning">Pause</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success mb-6">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Live Stats --}}
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6 w-full" x-data="broadcastProgress({{ $broadcast->id }})" x-init="startPolling">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Recipients</div>
|
||||
<div class="stat-value text-primary" x-text="stats.total_recipients || {{ $broadcast->total_recipients }}">{{ $broadcast->total_recipients }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">Sent</div>
|
||||
<div class="stat-value text-info" x-text="stats.total_sent || {{ $broadcast->total_sent }}">{{ $broadcast->total_sent }}</div>
|
||||
<div class="stat-desc" x-text="progress + '%'">{{ $broadcast->total_recipients > 0 ? round(($broadcast->total_sent / $broadcast->total_recipients) * 100, 1) : 0 }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">Delivered</div>
|
||||
<div class="stat-value text-success" x-text="stats.total_delivered || {{ $broadcast->total_delivered }}">{{ $broadcast->total_delivered }}</div>
|
||||
<div class="stat-desc" x-text="stats.delivery_rate + '%'">{{ $broadcast->getDeliveryRate() }}%</div>
|
||||
</div>
|
||||
|
||||
@if($broadcast->channel === 'email')
|
||||
<div class="stat">
|
||||
<div class="stat-title">Opened</div>
|
||||
<div class="stat-value" x-text="stats.total_opened || {{ $broadcast->total_opened }}">{{ $broadcast->total_opened }}</div>
|
||||
<div class="stat-desc" x-text="stats.open_rate + '%'">{{ $broadcast->getOpenRate() }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">Clicked</div>
|
||||
<div class="stat-value" x-text="stats.total_clicked || {{ $broadcast->total_clicked }}">{{ $broadcast->total_clicked }}</div>
|
||||
<div class="stat-desc" x-text="stats.click_rate + '%'">{{ $broadcast->getClickRate() }}%</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Recent Events --}}
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Activity</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Event</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($recentEvents as $event)
|
||||
<tr>
|
||||
<td>{{ $event->user->name }}</td>
|
||||
<td>
|
||||
<span class="badge {{ $event->event === 'opened' ? 'badge-success' : ($event->event === 'clicked' ? 'badge-info' : 'badge-ghost') }}">
|
||||
{{ ucfirst($event->event) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ $event->occurred_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3" class="text-center">No events yet</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<a href="{{ route('seller.marketing.broadcasts.analytics', $broadcast) }}" class="btn btn-outline">View Full Analytics</a>
|
||||
<a href="{{ route('seller.marketing.broadcasts.recipients', $broadcast) }}" class="btn btn-outline">View All Recipients</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('broadcastProgress', (broadcastId) => ({
|
||||
stats: {},
|
||||
progress: 0,
|
||||
interval: null,
|
||||
|
||||
startPolling() {
|
||||
this.fetchProgress();
|
||||
this.interval = setInterval(() => {
|
||||
this.fetchProgress();
|
||||
}, 5000); // Poll every 5 seconds
|
||||
},
|
||||
|
||||
async fetchProgress() {
|
||||
try {
|
||||
const response = await fetch(`/s/marketing/broadcasts/${broadcastId}/progress`);
|
||||
const data = await response.json();
|
||||
this.stats = data.stats;
|
||||
this.progress = data.progress;
|
||||
|
||||
if (data.status === 'sent') {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch progress:', error);
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
121
resources/views/seller/marketing/templates/create.blade.php
Normal file
121
resources/views/seller/marketing/templates/create.blade.php
Normal file
@@ -0,0 +1,121 @@
|
||||
@extends('layouts.seller')
|
||||
|
||||
@section('title', 'Create Template')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('seller.marketing.templates.index', $business->slug) }}" class="btn btn-ghost btn-sm mb-2">
|
||||
← Back to Templates
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Create Email Template</h1>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('seller.marketing.templates.store', $business->slug) }}" method="POST">
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{{-- Sidebar --}}
|
||||
<div class="lg:col-span-1">
|
||||
<div class="card bg-base-100 shadow-xl sticky top-4">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">Template Settings</h3>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Template Name *</span>
|
||||
</label>
|
||||
<input type="text" name="name" value="{{ old('name') }}" placeholder="e.g., Summer Sale 2025" class="input input-bordered" required>
|
||||
@error('name')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Description</span>
|
||||
</label>
|
||||
<textarea name="description" placeholder="Brief description..." class="textarea textarea-bordered h-20">{{ old('description') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Category *</span>
|
||||
</label>
|
||||
<select name="category_id" class="select select-bordered" required>
|
||||
<option value="">Select category...</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
|
||||
{{ $category->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="template_type" value="{{ $templateType }}">
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h4 class="font-semibold mb-2">Available Merge Tags</h4>
|
||||
<div class="text-sm space-y-1">
|
||||
@foreach($mergeTags as $tag => $description)
|
||||
<div class="flex justify-between">
|
||||
<code class="text-xs">{{ $tag }}</code>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Main Content --}}
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-4">Template Content</h3>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Design JSON</span>
|
||||
</label>
|
||||
<textarea name="design_json" class="textarea textarea-bordered h-32" placeholder='{"components": []...}'>{{ old('design_json') }}</textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">GrapeJS design configuration (optional)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">MJML Content</span>
|
||||
</label>
|
||||
<textarea name="mjml_content" class="textarea textarea-bordered h-48" placeholder="<mjml>..."></textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">MJML markup for responsive emails (optional)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">HTML Content *</span>
|
||||
</label>
|
||||
<textarea name="html_content" class="textarea textarea-bordered h-64" placeholder="<html>..." required>{{ old('html_content') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Plain Text</span>
|
||||
</label>
|
||||
<textarea name="plain_text" class="textarea textarea-bordered h-32" placeholder="Plain text version...">{{ old('plain_text') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<a href="{{ route('seller.marketing.templates.index', $business->slug) }}" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg">Create Template</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
131
resources/views/seller/marketing/templates/edit.blade.php
Normal file
131
resources/views/seller/marketing/templates/edit.blade.php
Normal file
@@ -0,0 +1,131 @@
|
||||
@extends('layouts.seller')
|
||||
|
||||
@section('title', 'Edit Template')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('seller.marketing.templates.show', [$business->slug, $template]) }}" class="btn btn-ghost btn-sm mb-2">
|
||||
← Back to Template
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Edit Template</h1>
|
||||
<p class="text-base-content opacity-70">Update {{ $template->name }}</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('seller.marketing.templates.update', [$business->slug, $template]) }}">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{{-- Sidebar --}}
|
||||
<div class="lg:col-span-1">
|
||||
<div class="card bg-base-100 shadow-xl sticky top-4">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">Template Settings</h3>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Template Name *</span>
|
||||
</label>
|
||||
<input type="text" name="name" value="{{ old('name', $template->name) }}" class="input input-bordered" required>
|
||||
@error('name')
|
||||
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Description</span>
|
||||
</label>
|
||||
<textarea name="description" class="textarea textarea-bordered h-20">{{ old('description', $template->description) }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Category *</span>
|
||||
</label>
|
||||
<select name="category_id" class="select select-bordered" required>
|
||||
<option value="">Select category...</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}" {{ (old('category_id', $template->category_id) == $category->id) ? 'selected' : '' }}>
|
||||
{{ $category->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Change Notes</span>
|
||||
</label>
|
||||
<textarea name="change_notes" class="textarea textarea-bordered h-20" placeholder="Describe what changed...">{{ old('change_notes') }}</textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Saved in version history</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h4 class="font-semibold mb-2">Available Merge Tags</h4>
|
||||
<div class="text-sm space-y-1 max-h-48 overflow-y-auto">
|
||||
@foreach($mergeTags as $tag => $description)
|
||||
<div class="flex justify-between">
|
||||
<code class="text-xs">{{ $tag }}</code>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Main Content --}}
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-4">Template Content</h3>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Design JSON</span>
|
||||
</label>
|
||||
<textarea name="design_json" class="textarea textarea-bordered h-32 font-mono text-xs">{{ old('design_json', $template->design_json ? json_encode($template->design_json) : '') }}</textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">GrapeJS design configuration</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">MJML Content</span>
|
||||
</label>
|
||||
<textarea name="mjml_content" class="textarea textarea-bordered h-48 font-mono text-xs">{{ old('mjml_content', $template->mjml_content) }}</textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">MJML markup for responsive emails</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">HTML Content *</span>
|
||||
</label>
|
||||
<textarea name="html_content" class="textarea textarea-bordered h-64 font-mono text-xs" required>{{ old('html_content', $template->html_content) }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Plain Text</span>
|
||||
</label>
|
||||
<textarea name="plain_text" class="textarea textarea-bordered h-32">{{ old('plain_text', $template->plain_text) }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<a href="{{ route('seller.marketing.templates.show', [$business->slug, $template]) }}" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg">Update Template</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
219
resources/views/seller/marketing/templates/index.blade.php
Normal file
219
resources/views/seller/marketing/templates/index.blade.php
Normal file
@@ -0,0 +1,219 @@
|
||||
{{-- resources/views/seller/marketing/templates/index.blade.php --}}
|
||||
@extends('layouts.seller')
|
||||
|
||||
@section('title', 'Email Templates')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
{{-- Header --}}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Email Templates</h1>
|
||||
<p class="text-gray-600 mt-1">Create and manage your email templates</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('seller.marketing.templates.create', $business->slug) }}" class="btn btn-primary">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
New Template
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Filters --}}
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body py-4">
|
||||
<form method="GET" class="flex flex-wrap gap-4">
|
||||
{{-- Search --}}
|
||||
<div class="form-control flex-1 min-w-[200px]">
|
||||
<input type="text"
|
||||
name="search"
|
||||
value="{{ request('search') }}"
|
||||
placeholder="Search templates..."
|
||||
class="input input-bordered">
|
||||
</div>
|
||||
|
||||
{{-- Category Filter --}}
|
||||
<div class="form-control min-w-[150px]">
|
||||
<select name="category" class="select select-bordered">
|
||||
<option value="">All Categories</option>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}"
|
||||
{{ request('category') == $category->id ? 'selected' : '' }}>
|
||||
{{ $category->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Brand Filter --}}
|
||||
@if($brands->count() > 0)
|
||||
<div class="form-control min-w-[150px]">
|
||||
<select name="brand" class="select select-bordered">
|
||||
<option value="">All Brands</option>
|
||||
@foreach($brands as $brand)
|
||||
<option value="{{ $brand->id }}"
|
||||
{{ request('brand') == $brand->id ? 'selected' : '' }}>
|
||||
{{ $brand->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Sort --}}
|
||||
<div class="form-control min-w-[150px]">
|
||||
<select name="sort" class="select select-bordered">
|
||||
<option value="recent" {{ request('sort') == 'recent' ? 'selected' : '' }}>Most Recent</option>
|
||||
<option value="popular" {{ request('sort') == 'popular' ? 'selected' : '' }}>Most Popular</option>
|
||||
<option value="name" {{ request('sort') == 'name' ? 'selected' : '' }}>Name A-Z</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Apply --}}
|
||||
<button type="submit" class="btn btn-primary">Apply</button>
|
||||
|
||||
@if(request()->hasAny(['search', 'category', 'brand', 'sort']))
|
||||
<a href="{{ route('seller.marketing.templates.index', $business->slug) }}" class="btn btn-ghost">
|
||||
Clear
|
||||
</a>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Template Grid --}}
|
||||
@if($templates->count() > 0)
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
@foreach($templates as $template)
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
{{-- Thumbnail --}}
|
||||
<figure class="h-48 bg-gray-100 relative">
|
||||
@if($template->thumbnail)
|
||||
<img src="{{ $template->thumbnail }}"
|
||||
alt="{{ $template->name }}"
|
||||
class="w-full h-full object-cover">
|
||||
@else
|
||||
<div class="flex items-center justify-center w-full h-full">
|
||||
<svg class="w-16 h-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Category Badge --}}
|
||||
@if($template->category)
|
||||
<div class="badge badge-primary absolute top-2 left-2">
|
||||
{{ $template->category->name }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- System Template Badge --}}
|
||||
@if($template->is_system_template)
|
||||
<div class="badge badge-accent absolute top-2 right-2">
|
||||
Pre-built
|
||||
</div>
|
||||
@endif
|
||||
</figure>
|
||||
|
||||
<div class="card-body p-4">
|
||||
{{-- Title --}}
|
||||
<h3 class="card-title text-lg">
|
||||
<a href="{{ route('seller.marketing.templates.show', [$business->slug, $template]) }}"
|
||||
class="hover:text-primary">
|
||||
{{ $template->name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{{-- Description --}}
|
||||
@if($template->description)
|
||||
<p class="text-sm text-gray-600 line-clamp-2">
|
||||
{{ $template->description }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{-- Tags --}}
|
||||
@if($template->tags)
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
@foreach(array_slice($template->tags, 0, 3) as $tag)
|
||||
<span class="badge badge-sm">{{ $tag }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500 mt-3">
|
||||
<span class="tooltip" data-tip="Times used">
|
||||
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
{{ number_format($template->usage_count) }}
|
||||
</span>
|
||||
<span>v{{ $template->version }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Actions --}}
|
||||
<div class="card-actions justify-end mt-4">
|
||||
@if($template->is_editable)
|
||||
<a href="{{ route('seller.marketing.templates.edit', [$business->slug, $template]) }}"
|
||||
class="btn btn-sm btn-ghost">
|
||||
Edit
|
||||
</a>
|
||||
@else
|
||||
<form action="{{ route('seller.marketing.templates.duplicate', [$business->slug, $template]) }}"
|
||||
method="POST" class="inline">
|
||||
@csrf
|
||||
<input type="hidden" name="name" value="{{ $template->name }} (Copy)">
|
||||
<button type="submit" class="btn btn-sm btn-ghost">
|
||||
Duplicate
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<a href="{{ route('seller.marketing.templates.show', [$business->slug, $template]) }}"
|
||||
class="btn btn-sm btn-primary">
|
||||
Use
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div class="mt-8">
|
||||
{{ $templates->links() }}
|
||||
</div>
|
||||
@else
|
||||
{{-- Empty State --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-16">
|
||||
<svg class="w-24 h-24 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<h3 class="text-2xl font-bold mb-2">No templates found</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
@if(request()->hasAny(['search', 'category', 'brand']))
|
||||
Try adjusting your filters or search terms
|
||||
@else
|
||||
Create your first email template to get started
|
||||
@endif
|
||||
</p>
|
||||
<div class="flex gap-2 justify-center">
|
||||
@if(request()->hasAny(['search', 'category', 'brand']))
|
||||
<a href="{{ route('seller.marketing.templates.index', $business->slug) }}" class="btn btn-ghost">
|
||||
Clear Filters
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ route('seller.marketing.templates.create', $business->slug) }}" class="btn btn-primary">
|
||||
Create Template
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
105
resources/views/seller/marketing/templates/show.blade.php
Normal file
105
resources/views/seller/marketing/templates/show.blade.php
Normal file
@@ -0,0 +1,105 @@
|
||||
@extends('layouts.seller')
|
||||
|
||||
@section('title', $template->name)
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('seller.marketing.templates.index', $business->slug) }}" class="btn btn-ghost btn-sm mb-2">
|
||||
← Back to Templates
|
||||
</a>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-3xl font-bold">{{ $template->name }}</h1>
|
||||
<span class="badge badge-primary badge-lg">{{ ucfirst($template->template_type) }}</span>
|
||||
</div>
|
||||
@if($template->description)
|
||||
<p class="text-base-content opacity-70 mt-2">{{ $template->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<form method="POST" action="{{ route('seller.marketing.templates.duplicate', [$business->slug, $template]) }}">
|
||||
@csrf
|
||||
<input type="hidden" name="name" value="{{ $template->name }} (Copy)">
|
||||
<button type="submit" class="btn btn-outline gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Duplicate
|
||||
</button>
|
||||
</form>
|
||||
@if($template->is_editable)
|
||||
<a href="{{ route('seller.marketing.templates.edit', [$business->slug, $template]) }}" class="btn btn-primary gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
Edit
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success mb-6">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6 w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Uses</div>
|
||||
<div class="stat-value text-primary">{{ number_format($template->usage_count) }}</div>
|
||||
<div class="stat-desc">Times this template was used</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">Created By</div>
|
||||
<div class="stat-value text-sm">{{ $template->creator->name }}</div>
|
||||
<div class="stat-desc">{{ $template->created_at->format('M j, Y') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">Version</div>
|
||||
<div class="stat-value">{{ $template->version }}</div>
|
||||
<div class="stat-desc">Current version</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Content Display --}}
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Template Content</h2>
|
||||
<div class="prose max-w-none">
|
||||
{!! $template->html_content !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Actions --}}
|
||||
@if($template->canBeDeleted())
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 class="font-semibold">Danger Zone</h3>
|
||||
<p class="text-sm text-base-content opacity-70">Permanently delete this template</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('seller.marketing.templates.destroy', [$business->slug, $template]) }}" onsubmit="return confirm('Delete this template? This cannot be undone.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-error btn-outline gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
Delete Template
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -3,8 +3,8 @@
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
{{-- Top Header Bar --}}
|
||||
<div class="bg-white border border-gray-300 rounded-md shadow-sm mb-6">
|
||||
<div class="px-6 py-4">
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
{{-- Left: Product Image & Info --}}
|
||||
<div class="flex items-start gap-4">
|
||||
@@ -13,10 +13,10 @@
|
||||
@if($product->images->where('is_primary', true)->first())
|
||||
<img src="{{ asset('storage/' . $product->images->where('is_primary', true)->first()->path) }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="w-16 h-16 object-cover rounded-md border border-gray-300">
|
||||
class="w-16 h-16 object-cover rounded-md border border-base-300">
|
||||
@else
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-md border border-gray-300 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-16 h-16 bg-base-200 rounded-md border border-base-300 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@
|
||||
{{-- Product Details --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h1 class="text-xl font-bold text-gray-900">{{ $product->name }}</h1>
|
||||
<h1 class="text-xl font-bold text-base-content">{{ $product->name }}</h1>
|
||||
{{-- Status Badges --}}
|
||||
<span id="activeBadge" style="display: {{ $product->is_active ? 'inline-flex' : 'none' }};" class="items-center px-2.5 py-0.5 rounded text-xs font-medium bg-success text-white">
|
||||
Active
|
||||
@@ -35,39 +35,30 @@
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-0.5">
|
||||
<div class="text-sm text-base-content/70 space-y-0.5">
|
||||
<div><span class="font-medium">SKU:</span> <span class="font-mono">{{ $product->sku ?? 'N/A' }}</span> <span class="mx-2">•</span> <span class="font-medium">Brand:</span> {{ $product->brand->name ?? 'N/A' }}</div>
|
||||
<div><span class="font-medium">Last updated:</span> {{ $product->updated_at->format('M j, Y g:i A') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Right: Action Buttons & Breadcrumb --}}
|
||||
{{-- Right: Action Buttons --}}
|
||||
<div class="flex flex-col gap-2 flex-shrink-0">
|
||||
{{-- View on Marketplace Button (White with border) --}}
|
||||
<a href="#" target="_blank" class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{{-- View on Marketplace Button --}}
|
||||
<a href="#" target="_blank" class="btn btn-outline btn-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
View on Marketplace
|
||||
</a>
|
||||
|
||||
{{-- Manage BOM Button (Blue solid) --}}
|
||||
<a href="{{ route('seller.business.products.bom.index', [$business->slug, $product->id]) }}" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white bg-primary hover:bg-primary/90 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{{-- Manage BOM Button --}}
|
||||
<a href="{{ route('seller.business.products.bom.index', [$business->slug, $product->id]) }}" class="btn btn-primary btn-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
Manage BOM
|
||||
</a>
|
||||
|
||||
{{-- Breadcrumb Navigation --}}
|
||||
<nav class="flex text-xs text-gray-500 mt-1" aria-label="Breadcrumb">
|
||||
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="hover:text-gray-700">Dashboard</a>
|
||||
<span class="mx-2">></span>
|
||||
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="hover:text-gray-700">Products</a>
|
||||
<span class="mx-2">></span>
|
||||
<span class="text-gray-900 font-medium">Edit</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +95,7 @@
|
||||
{{-- LEFT SIDEBAR (1/4 width) --}}
|
||||
<div class="space-y-6">
|
||||
{{-- Product Images Card --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-sm">Product Images</h2>
|
||||
<div class="space-y-4">
|
||||
@@ -147,7 +138,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Quick Stats Card --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-sm">Quick Stats</h2>
|
||||
<div class="space-y-2">
|
||||
@@ -173,7 +164,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Audit Info Card --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xs">Audit Info</h2>
|
||||
<div class="space-y-1 text-xs text-base-content/70">
|
||||
@@ -186,7 +177,7 @@
|
||||
|
||||
{{-- MAIN CONTENT WITH TABS (3/4 width) --}}
|
||||
<div class="lg:col-span-3">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
{{-- Tabs Navigation --}}
|
||||
<div role="tablist" class="tabs tabs-bordered">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,68 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Storage Test</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="p-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">File Storage Test</h2>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<span class="text-sm">Current Disk: <strong>{{ config('filesystems.default') }}</strong></span>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('storage.test') }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Upload Test File</span>
|
||||
</label>
|
||||
<input type="file" name="test_file" class="file-input file-input-bordered" required>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button type="submit" class="btn btn-primary">Test Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="divider">Storage Info</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="font-semibold">Disk</td>
|
||||
<td>{{ config('filesystems.default') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-semibold">Driver</td>
|
||||
<td>{{ config('filesystems.disks.'.config('filesystems.default').'.driver') }}</td>
|
||||
</tr>
|
||||
@if(config('filesystems.default') === 's3')
|
||||
<tr>
|
||||
<td class="font-semibold">Endpoint</td>
|
||||
<td>{{ config('filesystems.disks.s3.endpoint') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-semibold">Bucket</td>
|
||||
<td>{{ config('filesystems.disks.s3.bucket') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-semibold">URL</td>
|
||||
<td>{{ config('filesystems.disks.s3.url') }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\Seller\DashboardV2Controller;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Custom route model binding for business by slug
|
||||
@@ -35,6 +36,24 @@ Route::bind('invoice', function (string $value) {
|
||||
->firstOrFail();
|
||||
});
|
||||
|
||||
// Custom route model binding for products (business-scoped via brand)
|
||||
// This ensures sellers can only access products from their own business
|
||||
Route::bind('product', function (string $value) {
|
||||
// Get the business from the route (will be resolved by business binding first)
|
||||
$business = request()->route('business');
|
||||
|
||||
if (! $business) {
|
||||
abort(404, 'Business not found');
|
||||
}
|
||||
|
||||
// Find product by ID that belongs to this business via brand
|
||||
$product = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($value);
|
||||
|
||||
return $product;
|
||||
});
|
||||
|
||||
// Seller-specific routes under /s/ prefix (moved from /b/)
|
||||
Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
// Root redirect to dashboard
|
||||
@@ -98,6 +117,9 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
// Business-scoped dashboard (main entry point after login)
|
||||
Route::get('/dashboard', [DashboardController::class, 'businessDashboard'])->name('dashboard');
|
||||
|
||||
// Dashboard v2 (Beta - new comprehensive dashboard)
|
||||
Route::get('/dashboard/v2', [DashboardV2Controller::class, 'index'])->name('dashboard.v2');
|
||||
|
||||
// Fleet Management (Drivers and Vehicles) - accessible after onboarding, before approval
|
||||
Route::prefix('fleet')->name('fleet.')->group(function () {
|
||||
Route::resource('drivers', \App\Http\Controllers\DriverController::class)
|
||||
@@ -169,7 +191,16 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
});
|
||||
|
||||
// Analytics and reporting
|
||||
Route::get('/analytics', [\App\Http\Controllers\AnalyticsController::class, 'index'])->name('analytics.index');
|
||||
Route::prefix('analytics')->name('analytics.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Analytics\AnalyticsDashboardController::class, 'index'])->name('index');
|
||||
Route::get('/products', [\App\Http\Controllers\Analytics\ProductAnalyticsController::class, 'index'])->name('products');
|
||||
Route::get('/products/{product}', [\App\Http\Controllers\Analytics\ProductAnalyticsController::class, 'show'])->name('products.show');
|
||||
Route::get('/buyers', [\App\Http\Controllers\Analytics\BuyerIntelligenceController::class, 'index'])->name('buyers');
|
||||
Route::get('/buyers/{buyer}', [\App\Http\Controllers\Analytics\BuyerIntelligenceController::class, 'show'])->name('buyers.show');
|
||||
Route::get('/marketing', [\App\Http\Controllers\Analytics\MarketingAnalyticsController::class, 'index'])->name('marketing');
|
||||
Route::get('/marketing/campaign/{campaign}', [\App\Http\Controllers\Analytics\MarketingAnalyticsController::class, 'campaign'])->name('marketing.campaign');
|
||||
Route::get('/sales', [\App\Http\Controllers\Analytics\SalesAnalyticsController::class, 'index'])->name('sales');
|
||||
});
|
||||
|
||||
// Document management
|
||||
Route::prefix('documents')->name('documents.')->group(function () {
|
||||
@@ -183,7 +214,6 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
Route::get('/create', [\App\Http\Controllers\Seller\ProductController::class, 'create'])->name('create');
|
||||
Route::post('/', [\App\Http\Controllers\Seller\ProductController::class, 'store'])->name('store');
|
||||
Route::get('/{product}/edit', [\App\Http\Controllers\Seller\ProductController::class, 'edit'])->name('edit');
|
||||
Route::get('/{product}/edit1', [\App\Http\Controllers\Seller\ProductController::class, 'edit1'])->name('edit1');
|
||||
Route::put('/{product}', [\App\Http\Controllers\Seller\ProductController::class, 'update'])->name('update');
|
||||
Route::delete('/{product}', [\App\Http\Controllers\Seller\ProductController::class, 'destroy'])->name('destroy');
|
||||
|
||||
@@ -196,21 +226,6 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
Route::delete('/component/{component}', [\App\Http\Controllers\Seller\Product\BomController::class, 'detach'])->name('detach');
|
||||
Route::post('/reorder', [\App\Http\Controllers\Seller\Product\BomController::class, 'reorder'])->name('reorder');
|
||||
});
|
||||
|
||||
// Product Image Management
|
||||
Route::prefix('{product}/images')->name('images.')->group(function () {
|
||||
Route::post('/upload', [\App\Http\Controllers\Seller\ProductImageController::class, 'upload'])->name('upload');
|
||||
Route::delete('/{image}', [\App\Http\Controllers\Seller\ProductImageController::class, 'delete'])->name('delete');
|
||||
Route::post('/reorder', [\App\Http\Controllers\Seller\ProductImageController::class, 'reorder'])->name('reorder');
|
||||
Route::post('/{image}/set-primary', [\App\Http\Controllers\Seller\ProductImageController::class, 'setPrimary'])->name('set-primary');
|
||||
});
|
||||
});
|
||||
|
||||
// Product Lines Management (business-scoped)
|
||||
Route::prefix('product-lines')->name('product-lines.')->group(function () {
|
||||
Route::post('/', [\App\Http\Controllers\Seller\ProductLineController::class, 'store'])->name('store');
|
||||
Route::put('/{productLine}', [\App\Http\Controllers\Seller\ProductLineController::class, 'update'])->name('update');
|
||||
Route::delete('/{productLine}', [\App\Http\Controllers\Seller\ProductLineController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Component Management (business-scoped)
|
||||
@@ -223,6 +238,84 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
Route::delete('/{component}', [\App\Http\Controllers\Seller\ComponentController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Brand Management (business-scoped)
|
||||
Route::prefix('brands')->name('brands.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Seller\BrandController::class, 'index'])->name('index');
|
||||
Route::get('/create', [\App\Http\Controllers\Seller\BrandController::class, 'create'])->name('create');
|
||||
Route::post('/', [\App\Http\Controllers\Seller\BrandController::class, 'store'])->name('store');
|
||||
Route::get('/{brand}/edit', [\App\Http\Controllers\Seller\BrandController::class, 'edit'])->name('edit');
|
||||
Route::put('/{brand}', [\App\Http\Controllers\Seller\BrandController::class, 'update'])->name('update');
|
||||
Route::delete('/{brand}', [\App\Http\Controllers\Seller\BrandController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// Brand Preview - allows sellers to preview their brand menu as buyers see it
|
||||
Route::get('/{brand}/browse/preview', [\App\Http\Controllers\Seller\BrandPreviewController::class, 'preview'])->name('preview');
|
||||
});
|
||||
|
||||
// Marketing - Broadcast Management (business-scoped)
|
||||
Route::prefix('marketing/broadcasts')->name('marketing.broadcasts.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'index'])->name('index');
|
||||
Route::get('/create', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'create'])->name('create');
|
||||
Route::post('/', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'store'])->name('store');
|
||||
Route::get('/{broadcast}', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'show'])->name('show');
|
||||
Route::get('/{broadcast}/edit', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'edit'])->name('edit');
|
||||
Route::patch('/{broadcast}', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'update'])->name('update');
|
||||
Route::delete('/{broadcast}', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// Actions
|
||||
Route::post('/{broadcast}/send', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'send'])->name('send');
|
||||
Route::post('/{broadcast}/pause', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'pause'])->name('pause');
|
||||
Route::post('/{broadcast}/resume', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'resume'])->name('resume');
|
||||
Route::post('/{broadcast}/cancel', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'cancel'])->name('cancel');
|
||||
Route::post('/{broadcast}/duplicate', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'duplicate'])->name('duplicate');
|
||||
|
||||
// AJAX
|
||||
Route::get('/{broadcast}/progress', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'progress'])->name('progress');
|
||||
|
||||
// Views
|
||||
Route::get('/{broadcast}/recipients', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'recipients'])->name('recipients');
|
||||
Route::get('/{broadcast}/analytics', [\App\Http\Controllers\Seller\Marketing\BroadcastController::class, 'analytics'])->name('analytics');
|
||||
});
|
||||
|
||||
// Marketing - Template Management (business-scoped)
|
||||
Route::prefix('marketing/templates')->name('marketing.templates.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'index'])->name('index');
|
||||
Route::get('/create', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'create'])->name('create');
|
||||
Route::post('/', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'store'])->name('store');
|
||||
Route::get('/{template}', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'show'])->name('show');
|
||||
Route::get('/{template}/edit', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'edit'])->name('edit');
|
||||
Route::patch('/{template}', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'update'])->name('update');
|
||||
Route::delete('/{template}', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// Actions
|
||||
Route::post('/{template}/duplicate', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'duplicate'])->name('duplicate');
|
||||
Route::post('/{template}/send-test', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'sendTest'])->name('send-test');
|
||||
Route::post('/{template}/restore-version', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'restoreVersion'])->name('restore-version');
|
||||
|
||||
// Brand Management
|
||||
Route::post('/{template}/brands/add', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'addToBrand'])->name('brands.add');
|
||||
Route::delete('/{template}/brands/remove', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'removeFromBrand'])->name('brands.remove');
|
||||
Route::post('/{template}/brands/toggle-favorite', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'toggleFavorite'])->name('brands.toggle-favorite');
|
||||
|
||||
// Import/Export
|
||||
Route::post('/import', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'import'])->name('import');
|
||||
Route::get('/{template}/export/{format}', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'export'])->name('export')
|
||||
->where('format', 'html|mjml|zip');
|
||||
|
||||
// AJAX endpoints
|
||||
Route::get('/{template}/preview', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'preview'])->name('preview');
|
||||
Route::get('/{template}/analytics', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'analytics'])->name('analytics');
|
||||
Route::get('/{template}/versions', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'versions'])->name('versions');
|
||||
|
||||
// AI Endpoints
|
||||
Route::post('/ai/generate', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'aiGenerate'])->name('ai.generate');
|
||||
Route::post('/ai/subject-lines', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'aiSubjectLines'])->name('ai.subject-lines');
|
||||
Route::post('/ai/improve-copy', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'aiImproveCopy'])->name('ai.improve-copy');
|
||||
Route::post('/ai/check-spam', [\App\Http\Controllers\Seller\Marketing\TemplateController::class, 'aiCheckSpam'])->name('ai.check-spam');
|
||||
});
|
||||
|
||||
// View Switcher (business-scoped but outside settings group)
|
||||
Route::post('/view/switch', [\App\Http\Controllers\Seller\SettingsController::class, 'switchView'])->name('view.switch');
|
||||
|
||||
// Settings Management (business-scoped)
|
||||
Route::prefix('settings')->name('settings.')->group(function () {
|
||||
Route::get('/company-information', [\App\Http\Controllers\Seller\SettingsController::class, 'companyInformation'])->name('company-information');
|
||||
@@ -234,8 +327,25 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
Route::get('/invoices', [\App\Http\Controllers\Seller\SettingsController::class, 'invoices'])->name('invoices');
|
||||
Route::get('/manage-licenses', [\App\Http\Controllers\Seller\SettingsController::class, 'manageLicenses'])->name('manage-licenses');
|
||||
Route::get('/plans-and-billing', [\App\Http\Controllers\Seller\SettingsController::class, 'plansAndBilling'])->name('plans-and-billing');
|
||||
Route::post('/plans-and-billing/change-plan', [\App\Http\Controllers\Seller\SettingsController::class, 'changePlan'])->name('plans-and-billing.change-plan');
|
||||
Route::post('/plans-and-billing/cancel-downgrade', [\App\Http\Controllers\Seller\SettingsController::class, 'cancelDowngrade'])->name('plans-and-billing.cancel-downgrade');
|
||||
Route::get('/invoice/{invoiceId}', [\App\Http\Controllers\Seller\SettingsController::class, 'viewInvoice'])->name('invoice.view');
|
||||
Route::get('/invoice/{invoiceId}/download', [\App\Http\Controllers\Seller\SettingsController::class, 'downloadInvoice'])->name('invoice.download');
|
||||
Route::get('/notifications', [\App\Http\Controllers\Seller\SettingsController::class, 'notifications'])->name('notifications');
|
||||
Route::get('/reports', [\App\Http\Controllers\Seller\SettingsController::class, 'reports'])->name('reports');
|
||||
Route::get('/integrations', [\App\Http\Controllers\Seller\SettingsController::class, 'integrations'])->name('integrations');
|
||||
Route::get('/webhooks', [\App\Http\Controllers\Seller\SettingsController::class, 'webhooks'])->name('webhooks');
|
||||
Route::get('/audit-logs', [\App\Http\Controllers\Seller\SettingsController::class, 'auditLogs'])->name('audit-logs');
|
||||
|
||||
// Category Management (under settings)
|
||||
Route::prefix('categories')->name('categories.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Seller\CategoryController::class, 'index'])->name('index');
|
||||
Route::get('/create', [\App\Http\Controllers\Seller\CategoryController::class, 'create'])->name('create');
|
||||
Route::post('/', [\App\Http\Controllers\Seller\CategoryController::class, 'store'])->name('store');
|
||||
Route::get('/{category}/edit', [\App\Http\Controllers\Seller\CategoryController::class, 'edit'])->name('edit');
|
||||
Route::put('/{category}', [\App\Http\Controllers\Seller\CategoryController::class, 'update'])->name('update');
|
||||
Route::delete('/{category}', [\App\Http\Controllers\Seller\CategoryController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\StorageTestController;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -193,15 +192,15 @@ require __DIR__.'/buyer.php';
|
||||
// These routes have been migrated to the new buyer/seller structure
|
||||
// Route::prefix('b') conflicts are now resolved by using separate route files
|
||||
|
||||
// Analytics tracking endpoints (used by JavaScript tracker on buyer/seller pages)
|
||||
Route::prefix('analytics')->name('analytics.')->middleware('web')->group(function () {
|
||||
Route::post('/session', [\App\Http\Controllers\Analytics\TrackingController::class, 'session'])->name('session');
|
||||
Route::post('/track', [\App\Http\Controllers\Analytics\TrackingController::class, 'track'])->name('track');
|
||||
});
|
||||
|
||||
// API Routes for form validation (public access for registration)
|
||||
Route::prefix('api')->group(function () {
|
||||
Route::post('/check-email-availability', [\App\Http\Controllers\Api\EmailCheckController::class, 'checkAvailability'])
|
||||
->name('api.check-email')
|
||||
->middleware('throttle:10,1'); // Rate limit: 10 requests per minute
|
||||
});
|
||||
|
||||
// Storage Test Routes (Development/Testing Only)
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/storage-test', [StorageTestController::class, 'form'])->name('storage.test.form');
|
||||
Route::post('/storage-test', [StorageTestController::class, 'test'])->name('storage.test');
|
||||
});
|
||||
|
||||
537
tests/Feature/ProductImageControllerTest.php
Normal file
537
tests/Feature/ProductImageControllerTest.php
Normal file
@@ -0,0 +1,537 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductImage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProductImageControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('local');
|
||||
}
|
||||
|
||||
protected function withCsrfToken(): static
|
||||
{
|
||||
return $this->withSession(['_token' => 'test-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can upload valid product image
|
||||
*/
|
||||
public function test_seller_can_upload_valid_product_image(): void
|
||||
{
|
||||
// Create seller business with brand and product
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create valid test image (750x384 minimum)
|
||||
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$business->slug, $product->id]),
|
||||
['image' => $image]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
// Verify image was created in database
|
||||
$this->assertDatabaseHas('product_images', [
|
||||
'product_id' => $product->id,
|
||||
'is_primary' => true, // First image should be primary
|
||||
]);
|
||||
|
||||
// Verify file was stored
|
||||
$productImage = ProductImage::where('product_id', $product->id)->first();
|
||||
Storage::disk('local')->assertExists($productImage->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test first uploaded image becomes primary
|
||||
*/
|
||||
public function test_first_image_becomes_primary(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$business->slug, $product->id]),
|
||||
['image' => $image]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$productImage = ProductImage::where('product_id', $product->id)->first();
|
||||
$this->assertTrue($productImage->is_primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test image upload validates minimum dimensions
|
||||
*/
|
||||
public function test_upload_validates_minimum_dimensions(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Image too small (below 750x384)
|
||||
$image = UploadedFile::fake()->image('product.jpg', 500, 300);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$business->slug, $product->id]),
|
||||
['image' => $image]
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('image');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test upload validates file type
|
||||
*/
|
||||
public function test_upload_validates_file_type(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Invalid file type
|
||||
$file = UploadedFile::fake()->create('document.pdf', 100);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$business->slug, $product->id]),
|
||||
['image' => $file]
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('image');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cannot upload more than 6 images per product
|
||||
*/
|
||||
public function test_cannot_upload_more_than_six_images(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create 6 existing images
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => "products/test-{$i}.jpg",
|
||||
'is_primary' => $i === 0,
|
||||
'sort_order' => $i,
|
||||
]);
|
||||
}
|
||||
|
||||
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$business->slug, $product->id]),
|
||||
['image' => $image]
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJson([
|
||||
'success' => false,
|
||||
'message' => 'Maximum of 6 images allowed per product',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot upload image to another business's product (business_id isolation)
|
||||
*/
|
||||
public function test_seller_cannot_upload_image_to_other_business_product(): void
|
||||
{
|
||||
// Create two businesses
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
|
||||
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
|
||||
|
||||
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
|
||||
// Try to upload to businessB's product using businessA's slug
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$businessA->slug, $productB->id]),
|
||||
['image' => $image]
|
||||
);
|
||||
|
||||
$response->assertNotFound(); // Product not found when scoped to businessA
|
||||
|
||||
// Verify no image was created
|
||||
$this->assertDatabaseMissing('product_images', [
|
||||
'product_id' => $productB->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can delete their product's image
|
||||
*/
|
||||
public function test_seller_can_delete_product_image(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create test file
|
||||
Storage::disk('local')->put('products/test.jpg', 'fake content');
|
||||
|
||||
$image = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->deleteJson(
|
||||
route('seller.business.products.images.delete', [$business->slug, $product->id, $image->id])
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
// Verify image was deleted from database
|
||||
$this->assertDatabaseMissing('product_images', ['id' => $image->id]);
|
||||
|
||||
// Verify file was deleted from storage
|
||||
Storage::disk('local')->assertMissing('products/test.jpg');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test deleting primary image sets next image as primary
|
||||
*/
|
||||
public function test_deleting_primary_image_sets_next_as_primary(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create two images
|
||||
Storage::disk('local')->put('products/test1.jpg', 'fake content 1');
|
||||
Storage::disk('local')->put('products/test2.jpg', 'fake content 2');
|
||||
|
||||
$image1 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test1.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$image2 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test2.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
|
||||
// Delete primary image
|
||||
$response = $this->deleteJson(
|
||||
route('seller.business.products.images.delete', [$business->slug, $product->id, $image1->id])
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Verify image2 is now primary
|
||||
$this->assertTrue($image2->fresh()->is_primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot delete another business's product image
|
||||
*/
|
||||
public function test_seller_cannot_delete_other_business_product_image(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
|
||||
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
|
||||
|
||||
Storage::disk('local')->put('products/test.jpg', 'fake content');
|
||||
$imageB = ProductImage::create([
|
||||
'product_id' => $productB->id,
|
||||
'path' => 'products/test.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
|
||||
// Try to delete businessB's product image
|
||||
$response = $this->deleteJson(
|
||||
route('seller.business.products.images.delete', [$businessA->slug, $productB->id, $imageB->id])
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify image was NOT deleted
|
||||
$this->assertDatabaseHas('product_images', ['id' => $imageB->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can reorder product images
|
||||
*/
|
||||
public function test_seller_can_reorder_product_images(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create three images
|
||||
$image1 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test1.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$image2 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test2.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$image3 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test3.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
|
||||
// Reorder: image3, image1, image2
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.reorder', [$business->slug, $product->id]),
|
||||
['order' => [$image3->id, $image1->id, $image2->id]]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
// Verify new sort order
|
||||
$this->assertEquals(0, $image3->fresh()->sort_order);
|
||||
$this->assertEquals(1, $image1->fresh()->sort_order);
|
||||
$this->assertEquals(2, $image2->fresh()->sort_order);
|
||||
|
||||
// Verify first image (image3) is now primary
|
||||
$this->assertTrue($image3->fresh()->is_primary);
|
||||
$this->assertFalse($image1->fresh()->is_primary);
|
||||
$this->assertFalse($image2->fresh()->is_primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot reorder another business's product images
|
||||
*/
|
||||
public function test_seller_cannot_reorder_other_business_product_images(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
|
||||
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
|
||||
|
||||
$image1 = ProductImage::create([
|
||||
'product_id' => $productB->id,
|
||||
'path' => 'products/test1.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$image2 = ProductImage::create([
|
||||
'product_id' => $productB->id,
|
||||
'path' => 'products/test2.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
|
||||
// Try to reorder businessB's product images
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.reorder', [$businessA->slug, $productB->id]),
|
||||
['order' => [$image2->id, $image1->id]]
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify order was NOT changed
|
||||
$this->assertEquals(0, $image1->fresh()->sort_order);
|
||||
$this->assertEquals(1, $image2->fresh()->sort_order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can set image as primary
|
||||
*/
|
||||
public function test_seller_can_set_image_as_primary(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create two images
|
||||
$image1 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test1.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$image2 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test2.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
|
||||
// Set image2 as primary
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.set-primary', [$business->slug, $product->id, $image2->id])
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
// Verify image2 is now primary and image1 is not
|
||||
$this->assertTrue($image2->fresh()->is_primary);
|
||||
$this->assertFalse($image1->fresh()->is_primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot set primary on another business's product image
|
||||
*/
|
||||
public function test_seller_cannot_set_primary_on_other_business_product_image(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
|
||||
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
|
||||
|
||||
$image = ProductImage::create([
|
||||
'product_id' => $productB->id,
|
||||
'path' => 'products/test.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
|
||||
// Try to set primary on businessB's product image
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.set-primary', [$businessA->slug, $productB->id, $image->id])
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify is_primary was NOT changed
|
||||
$this->assertFalse($image->fresh()->is_primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cannot set primary on image that doesn't belong to product
|
||||
*/
|
||||
public function test_cannot_set_primary_on_image_from_different_product(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product1 = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
$product2 = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create image for product2
|
||||
$image = ProductImage::create([
|
||||
'product_id' => $product2->id,
|
||||
'path' => 'products/test.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
|
||||
// Try to set it as primary for product1 (wrong product)
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.set-primary', [$business->slug, $product1->id, $image->id])
|
||||
);
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJson([
|
||||
'success' => false,
|
||||
'message' => 'Image not found',
|
||||
]);
|
||||
}
|
||||
}
|
||||
324
tests/Feature/ProductLineControllerTest.php
Normal file
324
tests/Feature/ProductLineControllerTest.php
Normal file
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\ProductLine;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProductLineControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test seller can create product line for their business
|
||||
*/
|
||||
public function test_seller_can_create_product_line(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->post(
|
||||
route('seller.business.product-lines.store', $business->slug),
|
||||
['name' => 'Premium Line']
|
||||
);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Product line created successfully.');
|
||||
|
||||
$this->assertDatabaseHas('product_lines', [
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test product line name is required
|
||||
*/
|
||||
public function test_product_line_name_is_required(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->post(
|
||||
route('seller.business.product-lines.store', $business->slug),
|
||||
['name' => '']
|
||||
);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
$this->assertDatabaseMissing('product_lines', [
|
||||
'business_id' => $business->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test product line name must be unique per business
|
||||
*/
|
||||
public function test_product_line_name_must_be_unique_per_business(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
// Create existing product line
|
||||
ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->post(
|
||||
route('seller.business.product-lines.store', $business->slug),
|
||||
['name' => 'Premium Line']
|
||||
);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test product line name can be duplicated across different businesses
|
||||
*/
|
||||
public function test_product_line_name_can_be_duplicated_across_businesses(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$sellerB = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerB->businesses()->attach($businessB->id);
|
||||
|
||||
// Create product line in business A
|
||||
ProductLine::create([
|
||||
'business_id' => $businessA->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
// Create product line with same name in business B (should work)
|
||||
$this->actingAs($sellerB);
|
||||
$response = $this->post(
|
||||
route('seller.business.product-lines.store', $businessB->slug),
|
||||
['name' => 'Premium Line']
|
||||
);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success');
|
||||
|
||||
// Verify both exist
|
||||
$this->assertDatabaseHas('product_lines', [
|
||||
'business_id' => $businessA->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('product_lines', [
|
||||
'business_id' => $businessB->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can update their product line
|
||||
*/
|
||||
public function test_seller_can_update_product_line(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$productLine = ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->put(
|
||||
route('seller.business.product-lines.update', [$business->slug, $productLine->id]),
|
||||
['name' => 'Ultra Premium Line']
|
||||
);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Product line updated successfully.');
|
||||
|
||||
$this->assertDatabaseHas('product_lines', [
|
||||
'id' => $productLine->id,
|
||||
'name' => 'Ultra Premium Line',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('product_lines', [
|
||||
'id' => $productLine->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test update validates name is required
|
||||
*/
|
||||
public function test_update_validates_name_is_required(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$productLine = ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->put(
|
||||
route('seller.business.product-lines.update', [$business->slug, $productLine->id]),
|
||||
['name' => '']
|
||||
);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
|
||||
// Verify name wasn't changed
|
||||
$this->assertEquals('Premium Line', $productLine->fresh()->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test update validates uniqueness per business
|
||||
*/
|
||||
public function test_update_validates_uniqueness_per_business(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$productLine1 = ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$productLine2 = ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Budget Line',
|
||||
]);
|
||||
|
||||
// Try to rename productLine2 to match productLine1
|
||||
$this->actingAs($seller);
|
||||
$response = $this->put(
|
||||
route('seller.business.product-lines.update', [$business->slug, $productLine2->id]),
|
||||
['name' => 'Premium Line']
|
||||
);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
|
||||
// Verify name wasn't changed
|
||||
$this->assertEquals('Budget Line', $productLine2->fresh()->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot update another business's product line
|
||||
*/
|
||||
public function test_seller_cannot_update_other_business_product_line(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$productLineB = ProductLine::create([
|
||||
'business_id' => $businessB->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
$response = $this->put(
|
||||
route('seller.business.product-lines.update', [$businessA->slug, $productLineB->id]),
|
||||
['name' => 'Hacked Name']
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify name wasn't changed
|
||||
$this->assertEquals('Premium Line', $productLineB->fresh()->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can delete their product line
|
||||
*/
|
||||
public function test_seller_can_delete_product_line(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$productLine = ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->delete(
|
||||
route('seller.business.product-lines.destroy', [$business->slug, $productLine->id])
|
||||
);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Product line deleted successfully.');
|
||||
|
||||
$this->assertDatabaseMissing('product_lines', [
|
||||
'id' => $productLine->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot delete another business's product line
|
||||
*/
|
||||
public function test_seller_cannot_delete_other_business_product_line(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$productLineB = ProductLine::create([
|
||||
'business_id' => $businessB->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
$response = $this->delete(
|
||||
route('seller.business.product-lines.destroy', [$businessA->slug, $productLineB->id])
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify product line wasn't deleted
|
||||
$this->assertDatabaseHas('product_lines', [
|
||||
'id' => $productLineB->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller from unauthorized business cannot access another business routes
|
||||
*/
|
||||
public function test_seller_cannot_access_unauthorized_business_routes(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
|
||||
// Try to access businessB routes
|
||||
$response = $this->post(
|
||||
route('seller.business.product-lines.store', $businessB->slug),
|
||||
['name' => 'Test Line']
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user