Compare commits

...

16 Commits

Author SHA1 Message Date
kelly
31a50265ac feat: Add category management and UUID support for users
- Add product_categories and component_categories tables with hierarchical support
- Add UUID support to User model (18-char format matching Business model)
- Update category models with proper foreign key relationships
- Add category import script with 162 cannabis categories (76 product, 86 component)
- Fix user route model binding to use UUID instead of ID
- Update sidebar: user block above version, sticky bottom positioning
- Update account dropdown to handle null name fields gracefully

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 17:29:44 -07:00
kelly
ad849f9bcf Add comprehensive settings module enhancements
Settings Infrastructure:
- PermissionService: Role-based access control service
- AuditLog model: Track all settings changes
- ViewSwitcherController: Toggle between business views

Enhanced Settings Pages:
- Profile: User profile management with password updates
- Sales Config: Consolidated orders/invoices settings
- Brand Kit: Brand assets and style guide management
- Integrations: Third-party service connections
- Webhooks: Event-driven notifications setup
- Audit Logs: Complete settings change history
- Category Management: Product and component categorization

User Management:
- Enhanced user list with role templates
- User editing with permissions
- User invitation system
- Remove users functionality

Account Dropdown Component:
- Quick access to all settings pages
- Profile and notification shortcuts
- Organized settings sections

Routes & Bindings:
- 20+ new settings routes
- User route model binding
- Category management routes
- View switcher route

Documentation:
- Settings protection guide
- Settings validation reference
- Settings lock mechanism docs
- Work protection guide
- Settings check script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 14:38:35 -07:00
Jon
7887a695f7 Merge pull request 'Add module isolation structure for parallel development' (#49) from feature/manufacturing-route-isolation into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/49
Reviewed-by: Jon <contact@jonleopard.com>
2025-11-12 20:52:12 +00:00
kelly
654a76c5db Clarify core analytics vs Analytics module distinction
- Core analytics built into sales platform (always available)
- Analytics module for advanced BI and cross-module reporting
- Document permission structure for both
- Add examples showing when to use each
- Emphasize core B2B platform is NOT a "sales module"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 12:53:16 -07:00
kelly
a339d8fc75 Document complete route isolation architecture across /admin, /b, and /s
- Add comprehensive ROUTE_ISOLATION.md documentation
- Document /admin as separate isolated area (Filament Resources)
- Document buyer settings as required module at /b/{business}/settings/*
- Document seller settings as required module at /s/{business}/settings/*
- Clarify distinction between optional modules (flags) and required modules (permissions)
- Add examples of parallel development workflow
- Document module naming conventions and access control patterns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 12:37:39 -07:00
kelly
482789ca41 Add Settings as a formal module in isolation structure
Settings is now formally documented as a module alongside Manufacturing, Compliance, Marketing, and Analytics.

Key differences:
- Settings is always enabled (no has_settings flag needed)
- Controlled by role-based permissions rather than business flag
- Already has existing routes (company-information, users, brands, payments, etc.)

This provides:
- Development isolation for settings team
- Clear module boundary documentation
- Consistent pattern with other modules
- Permission control without feature flag overhead
2025-11-12 12:25:12 -07:00
kelly
28a66fba92 Add complete module isolation for B2B marketplace
Establishes route namespace isolation for all optional modules:

CORE SALES (always enabled):
- /s/{business}/* - Orders, products, brands, customers

OPTIONAL MODULES:
- /s/{business}/manufacturing/* - Production tracking (batches, wash-reports, conversions, work-orders)
- /s/{business}/compliance/* - Regulatory tracking (metrc, incoming-materials, lab-results)
- /s/{business}/marketing/* - Social media management, campaigns, email marketing
- /s/{business}/analytics/* - Business intelligence, cross-module reporting, executive dashboards

Adds module flags to businesses table:
- has_manufacturing (default: false)
- has_compliance (default: false)
- has_marketing (default: false)
- has_analytics (default: false)

KEY DISTINCTION:
- Each module has operational reports (e.g., manufacturing production reports)
- Analytics module is executive/BI layer that aggregates data across ALL modules
  (sales trends, product performance, customer insights, manufacturing costs, marketing ROI)

This prevents route collisions when multiple devs work on different modules
and allows per-business feature enablement in the B2B marketplace.
2025-11-12 12:18:53 -07:00
Jon
84f364de74 Merge pull request 'Cleanup product PR: Remove debug files and add tests' (#32) from fix/cleanup-product-pr-v2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/32
2025-11-06 23:39:28 +00:00
Jon Leopard
39c955cdc4 Fix ProductLineController route names
Changed all redirects from 'seller.business.products.index1' to
'seller.business.products.index' to match the actual route definition.

The index1 route doesn't exist in origin/develop, causing test failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:31:46 -07:00
Jon Leopard
e02ca54187 Update drop shadow values to match dashboard styling
Changed all card shadows from shadow-xl to shadow to be consistent
with the dashboard page styling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
ac46ee004b Fix product edit header: theme support and remove breadcrumb
Fixed top header container styling issues:
- Changed hard-coded bg-white/gray colors to theme-aware DaisyUI classes
- Restored proper shadow (shadow-xl instead of shadow-sm)
- Updated all color classes to use base-* theme variables
- Converted buttons to proper DaisyUI btn components
- Removed breadcrumb navigation element

Container now properly respects theme switcher (light/dark mode).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
17a6eb260d Add comprehensive tests for ProductLineController
Added test coverage for all ProductLineController methods:
- Store: validates required name, uniqueness per business, cross-business duplicates OK
- Update: validates name, uniqueness, business isolation
- Destroy: deletes product line, business isolation

Tests verify business_id scoping prevents cross-tenant access.

Note: Tests use standard HTTP methods (not JSON) which may have CSRF token issues
in current test environment (project-wide issue).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
5ea80366be Add comprehensive tests for ProductImageController
Added test coverage for all ProductImageController methods:
- Upload: validates dimensions, file type, max 6 images, business isolation
- Delete: handles primary image reassignment, business isolation
- Reorder: updates sort_order, sets first as primary, business isolation
- SetPrimary: updates is_primary flag, cross-product validation

Also fixed ProductImage model to include sort_order in fillable/casts.

Note: Tests currently fail with 419 CSRF errors (project-wide test issue affecting
PUT/POST/DELETE requests). Tests are correctly structured and will pass once CSRF
handling is fixed in the test environment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
99aa0cb980 Remove development and test artifacts from product PR
Removed debugging tools and test files that should not be in production:
- check_blade.php and check_blade.js (Blade syntax checkers)
- StorageTestController and storage-test view (MinIO testing scaffolds)
- edit.blade.php.backup and edit1.blade.php (development iterations)
- Storage test routes from web.php

These files were used during development but are not needed in the codebase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon
3de53a76d0 Merge pull request 'docs: add comprehensive guide for keeping feature branches up-to-date' (#30) from docs/add-feature-branch-sync-guide-clean into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/30
2025-11-06 22:17:08 +00:00
Jon Leopard
7fa9b6aff8 docs: add comprehensive guide for keeping feature branches up-to-date
Added new section "Keeping Your Feature Branch Up-to-Date" covering:
- Daily start-of-work routine for syncing with develop
- Merge vs rebase best practices for teams
- Step-by-step conflict resolution guide
- When and how to ask for help with complex conflicts
- Real-world example of multi-day feature work

This addresses common questions from contributors about branch
management and helps prevent large merge conflicts by encouraging
regular syncing with develop.
2025-11-06 15:05:27 -07:00
57 changed files with 10113 additions and 3516 deletions

View File

@@ -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:**

232
SETTINGS-LOCK.md Normal file
View File

@@ -0,0 +1,232 @@
# Settings Protection Guide
**IMPORTANT**: This document describes how to protect the settings system from being overwritten during branch merges.
## What's Protected
The settings system consists of:
- **Routes**: `routes/seller.php` (settings prefix section)
- **Controller**: `app/Http/Controllers/Seller/SettingsController.php`
- **Views**: `resources/views/seller/settings/*.blade.php` (14 files)
- **Services**: `app/Services/PermissionService.php`
- **Models**: `app/Models/AuditLog.php`
- **Components**: `resources/views/components/seller-account-dropdown.blade.php`
- **Navigation**: `resources/views/components/seller-sidebar.blade.php`
## Branch Merge Protection Strategy
### 1. Before Merging ANY Branch
Run the validation script to establish a baseline:
```bash
bash check-settings.sh
```
Expected output: "✓ All checks passed! No issues found."
If the script fails, **DO NOT PROCEED** with the merge until issues are resolved.
### 2. During Merge Conflicts
If you encounter merge conflicts in settings files:
#### For Routes (`routes/seller.php`)
- **Keep**: All routes under the `Route::prefix('settings')` block
- **Preserve**: User UUID route binding (lines 39-42)
- **Verify**: Settings routes match the list in SETTINGS-VALIDATION.md
#### For SettingsController
- **Keep**: All 25 methods listed in check-settings.sh
- **Do NOT remove**: Any method that exists in the current version
- **Add**: New methods from incoming branch (if any)
#### For Views (`resources/views/seller/settings/`)
- **Keep**: All 15 blade files (14 settings pages + users-edit)
- **Verify**: File sizes match expectations (see validation output)
- **Compare**: Use `git diff` to see what's being overwritten
#### For PermissionService
- **Keep**: All 5 categories (products, orders, inventory, customers, settings)
- **Keep**: The `audit-logs.view` permission under settings
- **Add**: New categories from incoming branch (merge, don't replace)
### 3. After Merging a Branch
**IMMEDIATELY** run the validation:
```bash
bash check-settings.sh
```
If any checks fail:
1. Identify what was overwritten using git diff
2. Restore from this commit using the recovery commands in SETTINGS-VALIDATION.md
3. Re-run validation until all checks pass
### 4. Git Strategy for Protection
#### Option A: Create a Protected Settings Branch
```bash
# Create a protected branch with final settings
git checkout -b settings-final
git add routes/seller.php app/Http/Controllers/Seller/SettingsController.php
git add resources/views/seller/settings/
git add app/Services/PermissionService.php app/Models/AuditLog.php
git add resources/views/components/seller-account-dropdown.blade.php
git add resources/views/components/seller-sidebar.blade.php
git commit -m "PROTECTED: Final settings implementation - DO NOT OVERWRITE"
# Tag it for easy reference
git tag -a settings-v1.0 -m "Locked settings implementation"
```
#### Option B: Use Git Attributes (Merge Driver)
Create `.gitattributes` in the root:
```
# Settings files - use "ours" strategy during merge
routes/seller.php merge=ours
app/Http/Controllers/Seller/SettingsController.php merge=ours
resources/views/seller/settings/*.blade.php merge=ours
app/Services/PermissionService.php merge=ours
```
Configure the merge driver:
```bash
git config merge.ours.driver "echo 'Keeping current version'; exit 0"
```
**WARNING**: This will ALWAYS keep your current version, so use carefully!
#### Option C: Cherry-Pick Strategy (Recommended)
Instead of merging entire branches, cherry-pick specific commits:
```bash
# List commits from a branch
git log feature-branch --oneline
# Cherry-pick specific commits that don't touch settings
git cherry-pick <commit-hash>
# If conflicts occur in settings files, abort and skip
git cherry-pick --abort
```
### 5. Recovery Protocol
If settings get overwritten during a merge:
```bash
# 1. Identify the last good commit (before merge)
git reflog
# 2. Restore settings files from the good commit
git show <commit-hash>:routes/seller.php > routes/seller.php
git show <commit-hash>:app/Http/Controllers/Seller/SettingsController.php > app/Http/Controllers/Seller/SettingsController.php
# For all views
for file in resources/views/seller/settings/*.blade.php; do
git show <commit-hash>:$file > $file
done
# 3. Restore services and models
git show <commit-hash>:app/Services/PermissionService.php > app/Services/PermissionService.php
git show <commit-hash>:app/Models/AuditLog.php > app/Models/AuditLog.php
# 4. Verify everything is restored
bash check-settings.sh
```
## Automated Protection (Pre-Merge Hook)
Create `.git/hooks/pre-merge-commit`:
```bash
#!/bin/bash
echo "🔒 Running settings validation before merge commit..."
if bash check-settings.sh; then
echo "✅ Settings validation passed"
exit 0
else
echo "❌ Settings validation FAILED!"
echo "The merge has broken the settings system."
echo "Please fix conflicts and run: bash check-settings.sh"
exit 1
fi
```
Make it executable:
```bash
chmod +x .git/hooks/pre-merge-commit
```
## When Merging PR Branches
For the 12 PR branches (pr01-pr12):
1. **Review First**: Check what each PR changes
```bash
git diff develop...pr01 -- routes/seller.php
git diff develop...pr01 -- app/Http/Controllers/Seller/SettingsController.php
```
2. **Selective Merge**: Only merge PRs that don't touch settings
```bash
# Safe to merge if no output
git diff develop...pr01 -- routes/seller.php app/Http/Controllers/Seller/SettingsController.php resources/views/seller/settings/
```
3. **If PR touches settings**: Manual review required
- Extract non-settings changes
- Apply them separately
- Skip settings changes OR carefully merge with validation
## Commit Message Convention
When making changes to settings files, use this prefix:
```
SETTINGS: <description>
Example:
SETTINGS: Add analytics permission to PermissionService
SETTINGS: Fix brand-kit route references
```
This makes it easy to find settings-related commits:
```bash
git log --grep="SETTINGS:"
```
## Final Checklist
Before considering settings "locked":
- [ ] Run `bash check-settings.sh` - all checks pass
- [ ] All 14 settings pages load without errors
- [ ] User permissions system works (can edit user permissions)
- [ ] Navigation (sidebar + dropdown) has correct links
- [ ] Create git tag: `git tag settings-v1.0`
- [ ] Document this commit hash: `git rev-parse HEAD`
- [ ] Create backup branch: `git branch settings-backup`
## Current Settings Commit Hash
Document the "golden" commit here:
```
Commit: 84f364de748651950be2404395366f253aef43bb
Tag: settings-v1.0
Backup Branch: settings-backup
Date: Mon, Nov 10, 2025 6:40:53 PM
Branch: develop
Status: ✅ All 14 settings pages validated and working
```
## Emergency Contacts
If settings get completely broken:
1. Run: `git checkout settings-backup`
2. Run: `bash check-settings.sh` to verify
3. Cherry-pick needed changes from other branches
4. DO NOT merge entire branches
---
**Remember**: The settings system is the foundation. Protect it at all costs!

View File

@@ -0,0 +1,110 @@
# Settings Protection - Quick Reference Card
## 🔒 Golden Commit
```
Commit: 84f364de748651950be2404395366f253aef43bb
Tag: settings-v1.0
Backup: settings-backup branch
```
## ✅ Before ANY Merge
```bash
bash check-settings.sh
```
Expected: "✓ All checks passed! No issues found."
## ⚠️ After EVERY Merge
```bash
bash check-settings.sh
```
If it fails, restore immediately!
## 🚨 Emergency Restore
```bash
# If settings get broken, restore from backup:
git checkout settings-backup -- routes/seller.php
git checkout settings-backup -- app/Http/Controllers/Seller/SettingsController.php
git checkout settings-backup -- resources/views/seller/settings/
git checkout settings-backup -- app/Services/PermissionService.php
git checkout settings-backup -- app/Models/AuditLog.php
# Verify restoration
bash check-settings.sh
```
## 📋 Protected Files
- `routes/seller.php` - Settings routes
- `app/Http/Controllers/Seller/SettingsController.php` - 25 methods
- `resources/views/seller/settings/*.blade.php` - 15 views
- `app/Services/PermissionService.php` - Permission system
- `app/Models/AuditLog.php` - Audit logging
- `resources/views/components/seller-account-dropdown.blade.php` - Dropdown menu
- `resources/views/components/seller-sidebar.blade.php` - Main navigation
## 🎯 Safe Merge Strategy
```bash
# Check what will change BEFORE merging
git diff develop...branch-name -- routes/seller.php
git diff develop...branch-name -- app/Http/Controllers/Seller/SettingsController.php
git diff develop...branch-name -- resources/views/seller/settings/
# If NO OUTPUT = safe to merge
# If OUTPUT = manual review required
```
## 📊 Validation Checklist
- [ ] 15 routes (including orders redirect)
- [ ] 25 controller methods
- [ ] 15 view files
- [ ] All views have content (>1000 bytes)
- [ ] PermissionService has 5 categories + audit-logs.view
- [ ] Navigation links point to correct routes
## 🔄 Recovery from Tag
```bash
# Restore all settings from the locked tag
git checkout settings-v1.0 -- routes/seller.php \
app/Http/Controllers/Seller/SettingsController.php \
resources/views/seller/settings/ \
app/Services/PermissionService.php \
app/Models/AuditLog.php
bash check-settings.sh
```
## Adding New Settings (ALLOWED)
You CAN add new settings pages/routes/methods. Just ensure:
```bash
# After adding new settings, update the validation script:
# 1. Add new route to EXPECTED_ROUTES array in check-settings.sh
# 2. Add new view to EXPECTED_VIEWS array
# 3. Add new method to EXPECTED_METHODS array
# 4. Run validation to confirm
bash check-settings.sh
```
**Example: Adding a new "Security" settings page**
1. Add route: `Route::get('/security', ...)->name('security');`
2. Add controller method: `public function security(Business $business) { ... }`
3. Add view: `resources/views/seller/settings/security.blade.php`
4. Update check-settings.sh arrays
5. Run validation
## 💡 Remember
**Additions are OK. Overwrites are NOT OK.**
Rules:
- ✅ ADD new routes, methods, views
- ✅ ADD new permissions to PermissionService
- ❌ REMOVE existing routes, methods, views
- ❌ MODIFY existing controller methods without validation
- ❌ CHANGE existing view file content without review
Before ANY merge:
1. Running validation BEFORE merge
2. Reviewing the changes manually
3. Running validation AFTER merge
4. Having the backup ready to restore
---
**If in doubt, ask first. It's easier to prevent than to fix!**

156
SETTINGS-VALIDATION.md Normal file
View File

@@ -0,0 +1,156 @@
# Settings Pages Validation
## Purpose
This document describes how to prevent regressions when working on the settings system.
## Problem
When recovering work from git history or making changes to routes/controllers, it's easy to accidentally break existing functionality by:
- Missing view files
- Missing routes
- Missing controller methods
- Breaking existing pages while fixing others
## Solution
Use the validation script before committing changes to settings-related code.
## Usage
### Run Validation
```bash
bash check-settings.sh
```
### Expected Output
All checks should pass:
```
======================================
Settings Routes & Views Validation
======================================
...
✓ All checks passed! No issues found.
```
### When to Run
1. **Before committing** changes to settings-related code
2. **After recovering** files from git history
3. **After adding** new settings pages
4. **When debugging** missing route/view errors
## What It Checks
### Routes (in routes/seller.php)
All required route names under the `seller.business.settings.*` prefix:
- company-information
- users
- sales-config
- orders (legacy redirect)
- invoices
- brand-kit (branding assets/settings)
- payments
- manage-licenses
- plans-and-billing
- notifications
- reports
- integrations
- webhooks
- audit-logs
- profile
### Views (in resources/views/seller/settings/)
All required Blade templates:
- company-information.blade.php
- users.blade.php
- users-edit.blade.php
- sales-config.blade.php
- invoices.blade.php
- brand-kit.blade.php
- payments.blade.php
- manage-licenses.blade.php
- plans-and-billing.blade.php
- notifications.blade.php
- reports.blade.php
- integrations.blade.php
- webhooks.blade.php
- audit-logs.blade.php
- profile.blade.php
### Controller Methods (in app/Http/Controllers/Seller/SettingsController.php)
All required public methods:
- companyInformation()
- updateCompanyInformation()
- users()
- editUser()
- inviteUser()
- updateUser()
- removeUser()
- salesConfig()
- updateSalesConfig()
- invoices()
- brandKit()
- payments()
- manageLicenses()
- plansAndBilling()
- changePlan()
- notifications()
- updateNotifications()
- reports()
- integrations()
- webhooks()
- auditLogs()
- profile()
- updateProfile()
- updatePassword()
## Adding New Settings Pages
When adding a new settings page:
1. Add the route to `routes/seller.php` in the settings group
2. Create the view file in `resources/views/seller/settings/`
3. Add the controller method(s) to `SettingsController.php`
4. Update `check-settings.sh` arrays:
- Add to `EXPECTED_ROUTES`
- Add to `EXPECTED_VIEWS`
- Add to `EXPECTED_METHODS`
5. Run validation: `bash check-settings.sh`
6. Update sidebar navigation in `resources/views/components/seller-sidebar.blade.php`
7. Update account dropdown if needed in `resources/views/components/seller-account-dropdown.blade.php`
## Notes
### Sales Config vs Orders/Invoices
- `/sales-config` is the main consolidated settings page
- `/orders` redirects to `/sales-config` (legacy compatibility)
- `/invoices` is a separate invoice-specific settings page
### Brands vs Brand Kit
- `/brands` (NOT in settings) - Main brand management for CRUD operations on brand entities
- `/settings/brand-kit` - Brand assets and branding settings (colors, logos, etc.)
### Categories
Categories have their own controller (`CategoryController`) and are managed separately from the main settings controller.
## Recovering Lost Work
If you need to recover lost settings work from git:
```bash
# Find commits with settings changes
git log --all --oneline -- "resources/views/seller/settings/"
# View specific file from commit
git show COMMIT_HASH:path/to/file.blade.php
# Recover file
git show COMMIT_HASH:path/to/file.blade.php > path/to/file.blade.php
# Validate everything is working
bash check-settings.sh
```
## Common Git Commits with Settings Work
- `70b3be1` - Add settings pages, analytics fixes, and brand management
- `9832d8e` - Restore subscription/billing work and settings improvements
- `89fbd33` - WIP: Save current work before applying stash
- `52facb7` - Merge latest develop branch with conflict resolution
- `66d55c4` - Add category management

276
WORK-PROTECTION-GUIDE.md Normal file
View File

@@ -0,0 +1,276 @@
# Work Protection Guide - November 10, 2025
## 🔒 Golden State Protected
**Current Branch**: `develop`
**Golden Backup**: `golden-backup-20251110`
**Working Backup**: `working-backup-20251110`
**Settings Backup**: `settings-backup` (from earlier)
## What's Protected
### 1. Settings System (15 Routes)
- Company Information
- Users Management
- Sales Config
- Invoice Settings
- Brand Kit
- Payments
- Manage Licenses
- Plans and Billing
- Notifications
- Reports
- Integrations
- Webhooks
- Audit Logs
- Categories Management
- Profile Settings
**Validation**: Run `bash check-settings.sh`
### 2. Manufacturing & Compliance
- Batch tracking (CRUD operations)
- Wash Reports (Stage 1 & 2)
- Conversion model with proper fillable fields
### 3. Brands Management
- Full CRUD operations
- Preview functionality
- Logo and banner uploads
### 4. Navigation & UI
- View Switcher (Sales/Manufacturing/Compliance)
- Manufacturing section in sidebar
- Brands section in sidebar
- All menu items properly initialized
## 🚨 Emergency Recovery
### If Anything Breaks
**Option 1: Restore Everything**
```bash
# Go back to golden state
git checkout golden-backup-20251110
# Create new working branch
git checkout -b fix/restore-working-state
# When satisfied, merge back
git checkout develop
git merge fix/restore-working-state
```
**Option 2: Restore Specific Files**
```bash
# Restore settings only
git checkout golden-backup-20251110 -- app/Http/Controllers/Seller/SettingsController.php
git checkout golden-backup-20251110 -- resources/views/seller/settings/
git checkout golden-backup-20251110 -- routes/seller.php
# Restore manufacturing
git checkout golden-backup-20251110 -- app/Http/Controllers/Seller/BatchController.php
git checkout golden-backup-20251110 -- app/Http/Controllers/Seller/WashReportController.php
git checkout golden-backup-20251110 -- app/Models/Conversion.php
# Restore brands
git checkout golden-backup-20251110 -- app/Http/Controllers/Seller/BrandController.php
git checkout golden-backup-20251110 -- resources/views/seller/brands/
```
**Option 3: Restore Settings (Quick)**
```bash
git checkout settings-backup -- routes/seller.php
git checkout settings-backup -- app/Http/Controllers/Seller/SettingsController.php
git checkout settings-backup -- resources/views/seller/settings/
git checkout settings-backup -- app/Services/PermissionService.php
git checkout settings-backup -- app/Models/AuditLog.php
```
## 📋 Handling Pull Requests
### Safe PR Merge Strategy
**BEFORE Merging Any PR:**
1. **Create test branch**
```bash
git checkout develop
git checkout -b test/pr-merge
```
2. **Check what will change**
```bash
# For each protected file
git diff develop...pr-branch -- routes/seller.php
git diff develop...pr-branch -- app/Http/Controllers/Seller/SettingsController.php
git diff develop...pr-branch -- resources/views/seller/settings/
git diff develop...pr-branch -- app/Http/Controllers/Seller/BatchController.php
git diff develop...pr-branch -- app/Http/Controllers/Seller/BrandController.php
git diff develop...pr-branch -- resources/views/components/seller-sidebar.blade.php
```
3. **Merge in test branch first**
```bash
git merge pr-branch
# If conflicts, resolve carefully
# NEVER blindly accept incoming changes to protected files
```
4. **Validate everything works**
```bash
bash check-settings.sh
# Test all pages manually
# Check batches, wash reports, brands
```
5. **If validation passes, merge to develop**
```bash
git checkout develop
git merge test/pr-merge
git push origin develop
```
6. **If validation fails, restore and investigate**
```bash
git checkout develop # Abandon test branch
# Figure out why it failed before trying again
```
### Protected Files - NEVER Auto-Accept Changes
These files should NEVER be overwritten by PR merges:
**Settings Core:**
- `routes/seller.php` (lines with settings routes)
- `app/Http/Controllers/Seller/SettingsController.php`
- `resources/views/seller/settings/*.blade.php`
- `app/Services/PermissionService.php`
- `app/Models/AuditLog.php`
**Manufacturing:**
- `app/Http/Controllers/Seller/BatchController.php`
- `app/Http/Controllers/Seller/WashReportController.php`
- `app/Models/Conversion.php`
- `resources/views/seller/batches/*.blade.php`
- `resources/views/seller/wash-reports/*.blade.php`
**Brands:**
- `app/Http/Controllers/Seller/BrandController.php`
- `resources/views/seller/brands/*.blade.php`
**Navigation:**
- `resources/views/components/seller-sidebar.blade.php`
- `resources/views/components/view-switcher.blade.php`
## 🎯 What to Do With PRs Now
### Step 1: Review All Open PRs
```bash
# List all PRs
gh pr list
# For each PR, check what files it changes
gh pr view <PR_NUMBER> --json files
```
### Step 2: Categorize PRs
**Safe to Merge** (no conflicts with protected files):
- PRs that don't touch routes, controllers, or views we fixed
- Documentation updates
- Test additions
- New features in separate areas
**Needs Careful Review** (touches protected files):
- Any PR modifying settings
- Any PR touching navigation
- Any PR changing routes
**Definitely Wait** (conflicts with our work):
- PRs that restructure controllers
- PRs that change route structure
- PRs that modify sidebar/navigation
### Step 3: Merge Priority
1. **First**: Safe PRs (no conflicts)
2. **Second**: PRs you NEED that have minor conflicts
3. **Last**: Major refactoring PRs
### Step 4: For Each PR
```bash
# Create isolated test branch
git checkout develop
git checkout -b test/pr-<number>
# Fetch PR
gh pr checkout <number>
# Check diff
git diff develop
# If looks safe, try merge
git checkout test/pr-<number>
git merge develop
# Test everything
bash check-settings.sh
# If good, merge
git checkout develop
git merge test/pr-<number>
git push
```
## 🛡️ Daily Protection Routine
**Every day before starting work:**
```bash
# Verify you're on develop
git branch --show-current
# Check status
git status
# Verify backups exist
git branch | grep backup
# If everything looks good, you're safe to work
```
**After making changes:**
```bash
# Commit frequently
git add -A
git commit -m "descriptive message"
# Push to remote (additional safety)
git push origin develop
```
## 📞 Recovery Contacts
**If you get stuck:**
1. DON'T PANIC
2. DON'T run `git reset --hard`
3. DON'T force push
4. Create new branch from current state: `git checkout -b emergency-save`
5. Then restore from golden backup
## 🎖️ Golden Rules
1. **NEVER** merge PRs directly into develop without testing
2. **ALWAYS** test in a separate branch first
3. **ALWAYS** run validation after any merge
4. **ALWAYS** keep golden-backup branch untouched
5. **NEVER** force push to develop
6. **COMMIT** frequently (can't lose committed work)
---
**Created**: November 10, 2025
**Last Updated**: November 10, 2025
**Status**: ✅ All systems working and protected

View File

@@ -0,0 +1,224 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\ComponentCategory;
use App\Models\ProductCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class CategoryController extends Controller
{
public function index(Business $business)
{
// Load product categories with nesting and counts
$productCategories = ProductCategory::where('business_id', $business->id)
->whereNull('parent_id')
->with(['children' => function ($query) {
$query->orderBy('sort_order')->orderBy('name');
}])
->withCount('products')
->orderBy('sort_order')
->orderBy('name')
->get();
// Load component categories with nesting and counts
$componentCategories = ComponentCategory::where('business_id', $business->id)
->whereNull('parent_id')
->with(['children' => function ($query) {
$query->orderBy('sort_order')->orderBy('name');
}])
->withCount('components')
->orderBy('sort_order')
->orderBy('name')
->get();
return view('seller.settings.categories.index', compact('business', 'productCategories', 'componentCategories'));
}
public function create(Business $business, string $type)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
// Get all categories of this type for parent selection
$categories = $type === 'product'
? ProductCategory::where('business_id', $business->id)
->whereNull('parent_id')
->with('children')
->orderBy('name')
->get()
: ComponentCategory::where('business_id', $business->id)
->whereNull('parent_id')
->with('children')
->orderBy('name')
->get();
return view('seller.settings.categories.create', compact('business', 'type', 'categories'));
}
public function store(Request $request, Business $business, string $type)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$tableName = $type === 'product' ? 'product_categories' : 'component_categories';
$validated = $request->validate([
'name' => 'required|string|max:255',
'parent_id' => "nullable|exists:{$tableName},id",
'description' => 'nullable|string',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'boolean',
'image' => 'nullable|image|max:2048',
]);
$validated['business_id'] = $business->id;
$validated['slug'] = Str::slug($validated['name']);
$validated['is_active'] = $request->has('is_active') ? true : false;
// Handle image upload
if ($request->hasFile('image')) {
$validated['image_path'] = $request->file('image')->store('categories', 'public');
}
// Validate parent belongs to same business if provided
if (! empty($validated['parent_id'])) {
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$parent = $model::where('business_id', $business->id)->find($validated['parent_id']);
if (! $parent) {
return back()->withErrors(['parent_id' => 'Invalid parent category'])->withInput();
}
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$model::create($validated);
return redirect()->route('seller.business.settings.categories.index', $business->slug)
->with('success', ucfirst($type).' category created successfully');
}
public function edit(Business $business, string $type, int $id)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$category = $model::where('business_id', $business->id)->findOrFail($id);
// Get all categories of this type for parent selection (excluding self and descendants)
$categories = $model::where('business_id', $business->id)
->whereNull('parent_id')
->where('id', '!=', $id)
->with('children')
->orderBy('name')
->get();
return view('seller.settings.categories.edit', compact('business', 'type', 'category', 'categories'));
}
public function update(Request $request, Business $business, string $type, int $id)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$category = $model::where('business_id', $business->id)->findOrFail($id);
$tableName = $type === 'product' ? 'product_categories' : 'component_categories';
$validated = $request->validate([
'name' => 'required|string|max:255',
'parent_id' => "nullable|exists:{$tableName},id",
'description' => 'nullable|string',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'boolean',
'image' => 'nullable|image|max:2048',
]);
$validated['slug'] = Str::slug($validated['name']);
$validated['is_active'] = $request->has('is_active') ? true : false;
// Handle image upload
if ($request->hasFile('image')) {
// Delete old image if exists
if ($category->image_path) {
\Storage::disk('public')->delete($category->image_path);
}
$validated['image_path'] = $request->file('image')->store('categories', 'public');
}
// Validate parent (can't be self or descendant)
if (! empty($validated['parent_id'])) {
if ($validated['parent_id'] == $id) {
return back()->withErrors(['parent_id' => 'Category cannot be its own parent'])->withInput();
}
$parent = $model::where('business_id', $business->id)->find($validated['parent_id']);
if (! $parent) {
return back()->withErrors(['parent_id' => 'Invalid parent category'])->withInput();
}
// Check for circular reference (if parent's parent is this category)
if ($parent->parent_id == $id) {
return back()->withErrors(['parent_id' => 'This would create a circular reference'])->withInput();
}
}
$category->update($validated);
return redirect()->route('seller.business.settings.categories.index', $business->slug)
->with('success', ucfirst($type).' category updated successfully');
}
public function destroy(Business $business, string $type, int $id)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$category = $model::where('business_id', $business->id)->findOrFail($id);
// Check if has products/components
if ($type === 'product') {
$count = $category->products()->count();
if ($count > 0) {
return back()->with('error', "Cannot delete category with {$count} products. Please reassign or delete products first.");
}
} else {
$count = $category->components()->count();
if ($count > 0) {
return back()->with('error', "Cannot delete category with {$count} components. Please reassign or delete components first.");
}
}
// Check if has children
$childCount = $category->children()->count();
if ($childCount > 0) {
return back()->with('error', "Cannot delete category with {$childCount} subcategories. Please delete or move subcategories first.");
}
// Delete image if exists
if ($category->image_path) {
\Storage::disk('public')->delete($category->image_path);
}
$category->delete();
return redirect()->route('seller.business.settings.categories.index', $business->slug)
->with('success', ucfirst($type).' category deleted successfully');
}
}

View File

@@ -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.');
}
}

View File

@@ -8,6 +8,89 @@ use Illuminate\Http\Request;
class SettingsController extends Controller
{
/**
* Display the personal profile page.
*/
public function profile(Business $business)
{
$user = auth()->user();
// Get login history (assuming a login_histories table exists)
$loginHistory = collect(); // Placeholder - will be implemented with login history tracking
return view('seller.settings.profile', compact('business', 'loginHistory'));
}
/**
* Update the personal profile.
*/
public function updateProfile(Business $business, Request $request)
{
$user = auth()->user();
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email,' . $user->id,
'avatar' => 'nullable|image|max:2048',
'remove_avatar' => 'nullable|boolean',
'use_gravatar' => 'nullable|boolean',
'linkedin_url' => 'nullable|url|max:255',
'twitter_url' => 'nullable|url|max:255',
'facebook_url' => 'nullable|url|max:255',
'instagram_url' => 'nullable|url|max:255',
'github_url' => 'nullable|url|max:255',
]);
// Handle avatar removal
if ($request->has('remove_avatar') && $user->avatar_path) {
\Storage::disk('public')->delete($user->avatar_path);
$validated['avatar_path'] = null;
}
// Handle avatar upload
if ($request->hasFile('avatar')) {
// Delete old avatar if exists
if ($user->avatar_path) {
\Storage::disk('public')->delete($user->avatar_path);
}
$path = $request->file('avatar')->store('avatars', 'public');
$validated['avatar_path'] = $path;
}
$user->update($validated);
return redirect()->route('seller.business.settings.profile', $business->slug)
->with('success', 'Profile updated successfully.');
}
/**
* Update the user's password.
*/
public function updatePassword(Business $business, Request $request)
{
$user = auth()->user();
$validated = $request->validate([
'current_password' => 'required|current_password',
'password' => 'required|string|min:8|confirmed',
'logout_other_sessions' => 'nullable|boolean',
]);
$user->update([
'password' => bcrypt($validated['password']),
]);
// Logout other sessions if requested
if ($request->has('logout_other_sessions')) {
auth()->logoutOtherDevices($validated['password']);
}
return redirect()->route('seller.business.settings.profile', $business->slug)
->with('success', 'Password updated successfully.');
}
/**
* Display the company information settings page.
*/
@@ -18,21 +101,12 @@ class SettingsController extends Controller
/**
* Update the company information.
* Note: Only business_phone and business_email can be updated due to compliance requirements.
*/
public function updateCompanyInformation(Business $business, Request $request)
{
// Only allow updating business phone and email (hybrid approach)
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'description' => 'nullable|string|max:1000',
'business_type' => 'nullable|string',
'tin_ein' => 'nullable|string|max:20',
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:2',
'physical_zipcode' => 'nullable|string|max:10',
'business_phone' => 'nullable|string|max:20',
'business_email' => 'nullable|email|max:255',
]);
@@ -41,39 +115,233 @@ class SettingsController extends Controller
return redirect()
->route('seller.business.settings.company-information', $business->slug)
->with('success', 'Company information updated successfully!');
->with('success', 'Contact information updated successfully!');
}
/**
* Display the users management settings page.
*/
public function users(Business $business)
public function users(Business $business, Request $request, \App\Services\PermissionService $permissionService)
{
return view('seller.settings.users', compact('business'));
// Exclude business owner from the users list
$query = $business->users()->where('users.id', '!=', $business->owner_user_id);
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by account type (role)
if ($request->filled('account_type')) {
$query->whereHas('roles', function ($q) use ($request) {
$q->where('name', $request->account_type);
});
}
// Filter by last login date range
if ($request->filled('last_login_start')) {
$query->where('last_login_at', '>=', $request->last_login_start);
}
if ($request->filled('last_login_end')) {
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
}
$users = $query
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
->with('roles')
->orderBy('last_name')
->orderBy('first_name')
->paginate(15);
$roleTemplates = $permissionService->getRoleTemplates();
$permissionCategories = $permissionService->getPermissionsByCategory();
return view('seller.settings.users', compact('business', 'users', 'roleTemplates', 'permissionCategories'));
}
/**
* Display the order settings page.
* Show the form for editing a user's permissions.
*/
public function orders(Business $business)
public function editUser(Business $business, \App\Models\User $user, \App\Services\PermissionService $permissionService)
{
return view('seller.settings.orders', compact('business'));
// Check if user belongs to this business
if (! $business->users()->where('users.id', $user->id)->exists()) {
abort(403, 'User does not belong to this business');
}
// Load user with pivot data
$user = $business->users()
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
->with('roles')
->where('users.id', $user->id)
->first();
$roleTemplates = $permissionService->getRoleTemplates();
$permissionCategories = $permissionService->getPermissionsByCategory();
$isOwner = $business->owner_user_id === $user->id;
return view('seller.settings.users-edit', compact('business', 'user', 'roleTemplates', 'permissionCategories', 'isOwner'));
}
/**
* Display the brands management page.
* Store a newly created user invitation.
*/
public function brands(Business $business)
public function inviteUser(Business $business, Request $request)
{
return view('seller.settings.brands', compact('business'));
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'phone' => 'nullable|string|max:20',
'position' => 'nullable|string|max:255',
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
'is_point_of_contact' => 'nullable|boolean',
]);
// Combine first and last name
$fullName = trim($validated['first_name'].' '.$validated['last_name']);
// Create user and associate with business
$user = \App\Models\User::create([
'name' => $fullName,
'email' => $validated['email'],
'phone' => $validated['phone'],
'password' => bcrypt(str()->random(32)), // Temporary password
]);
// Assign role
$user->assignRole($validated['role']);
// Associate with business with additional pivot data
$business->users()->attach($user->id, [
'role' => $validated['role'],
'is_primary' => false,
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
]);
// TODO: Send invitation email with password reset link
return redirect()
->route('seller.business.settings.users', $business->slug)
->with('success', 'User invited successfully!');
}
/**
* Display the payment settings page.
* Update user information and permissions.
*/
public function payments(Business $business)
public function updateUser(Business $business, \App\Models\User $user, Request $request)
{
return view('seller.settings.payments', compact('business'));
// Check if user belongs to this business
if (! $business->users()->where('users.id', $user->id)->exists()) {
abort(403, 'User does not belong to this business');
}
// Prevent modifying business owner
if ($business->owner_user_id === $user->id) {
return redirect()
->route('seller.business.settings.users.edit', [$business->slug, $user->id])
->with('error', 'Cannot modify business owner permissions.');
}
$validated = $request->validate([
'position' => 'nullable|string|max:255',
'company' => 'nullable|string|max:255',
'contact_type' => 'nullable|string|max:255',
'role' => 'nullable|string|max:255',
'role_template' => 'nullable|string|max:255',
'permissions' => 'nullable|array',
]);
// Update user data
$user->update([
'position' => $validated['position'] ?? null,
'company' => $validated['company'] ?? null,
]);
// Update business_user pivot data
$business->users()->updateExistingPivot($user->id, [
'contact_type' => $validated['contact_type'] ?? null,
'role' => $validated['role'] ?? null,
'role_template' => $validated['role_template'] ?? null,
'permissions' => $validated['permissions'] ?? null,
'permissions_updated_at' => now(),
]);
return redirect()
->route('seller.business.settings.users.edit', [$business->slug, $user->id])
->with('success', 'User updated successfully!');
}
/**
* Remove user from business.
*/
public function removeUser(Business $business, \App\Models\User $user)
{
// Check if user belongs to this business
if (! $business->users()->where('users.id', $user->id)->exists()) {
abort(403, 'User does not belong to this business');
}
// Detach user from business
$business->users()->detach($user->id);
return redirect()
->route('seller.business.settings.users', $business->slug)
->with('success', 'User removed successfully!');
}
/**
* Display the sales configuration page (orders + invoices).
*/
public function salesConfig(Business $business)
{
return view('seller.settings.sales-config', compact('business'));
}
/**
* Update the sales configuration settings (orders + invoices).
*/
public function updateSalesConfig(Business $business, Request $request)
{
$validated = $request->validate([
// Order settings
'separate_orders_by_brand' => 'nullable|boolean',
'auto_increment_order_ids' => 'nullable|boolean',
'show_mark_as_paid' => 'nullable|boolean',
'display_crm_license_on_orders' => 'nullable|boolean',
'order_minimum' => 'nullable|numeric|min:0',
'default_shipping_charge' => 'nullable|numeric|min:0',
'free_shipping_minimum' => 'nullable|numeric|min:0',
'order_disclaimer' => 'nullable|string|max:2000',
'order_invoice_footer' => 'nullable|string|max:1000',
'prevent_order_editing' => 'required|in:never,after_approval,after_fulfillment,always',
'az_require_patient_count' => 'nullable|boolean',
'az_require_allotment_verification' => 'nullable|boolean',
// Invoice settings
'invoice_payable_company_name' => 'nullable|string|max:255',
'invoice_payable_address' => 'nullable|string|max:255',
'invoice_payable_city' => 'nullable|string|max:100',
'invoice_payable_state' => 'nullable|string|max:2',
'invoice_payable_zipcode' => 'nullable|string|max:10',
]);
// Convert checkbox values (null means unchecked)
$validated['separate_orders_by_brand'] = $request->has('separate_orders_by_brand');
$validated['auto_increment_order_ids'] = $request->has('auto_increment_order_ids');
$validated['show_mark_as_paid'] = $request->has('show_mark_as_paid');
$validated['display_crm_license_on_orders'] = $request->has('display_crm_license_on_orders');
$validated['az_require_patient_count'] = $request->has('az_require_patient_count');
$validated['az_require_allotment_verification'] = $request->has('az_require_allotment_verification');
$business->update($validated);
return redirect()
->route('seller.business.settings.sales-config', $business->slug)
->with('success', 'Sales configuration updated successfully!');
}
/**
@@ -84,12 +352,32 @@ class SettingsController extends Controller
return view('seller.settings.invoices', compact('business'));
}
/**
* Display the brand kit page (Cannabrands assets/branding settings).
*/
public function brandKit(Business $business)
{
return view('seller.settings.brand-kit', compact('business'));
}
/**
* Display the payment settings page.
*/
public function payments(Business $business)
{
return view('seller.settings.payments', compact('business'));
}
/**
* Display the manage licenses page.
*/
public function manageLicenses(Business $business)
{
return view('seller.settings.manage-licenses', compact('business'));
// TODO: License table is currently a placeholder - needs migration update
// For now, return empty collection so the UI displays properly
$licenses = collect([]);
return view('seller.settings.manage-licenses', compact('business', 'licenses'));
}
/**
@@ -100,6 +388,150 @@ class SettingsController extends Controller
return view('seller.settings.plans-and-billing', compact('business'));
}
/**
* Change the business subscription plan.
*/
public function changePlan(Business $business, Request $request)
{
$validated = $request->validate([
'plan_id' => 'required|in:standard,business,premium',
]);
$planId = $validated['plan_id'];
// Define available plans with pricing
$plans = [
'standard' => ['name' => 'Marketplace Standard', 'price' => 99.00],
'business' => ['name' => 'Marketplace Business', 'price' => 395.00],
'premium' => ['name' => 'Marketplace Premium', 'price' => 795.00],
];
$newPlan = $plans[$planId];
// Get or create subscription
$subscription = $business->subscription()->firstOrCreate(
['business_id' => $business->id],
[
'plan_id' => 'standard',
'plan_name' => 'Marketplace Standard',
'plan_price' => 99.00,
'status' => 'active',
'current_period_start' => now(),
'current_period_end' => now()->addMonth(),
]
);
// Check if same plan
if ($subscription->plan_id === $planId) {
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('info', 'You are already on this plan.');
}
// Determine if upgrade or downgrade
$isUpgrade = $newPlan['price'] > $subscription->plan_price;
if ($isUpgrade) {
// UPGRADE: Calculate prorated charge and update immediately
$daysLeftInCycle = now()->diffInDays($subscription->current_period_end);
$proratedCredit = ($subscription->plan_price / 30) * $daysLeftInCycle;
$proratedCharge = ($newPlan['price'] / 30) * $daysLeftInCycle;
$amountToPay = $proratedCharge - $proratedCredit;
// Create invoice for the upgrade
$invoiceNumber = 'INV-' . now()->format('Y') . '-' . str_pad(\App\Models\SubscriptionInvoice::count() + 1, 5, '0', STR_PAD_LEFT);
$invoice = \App\Models\SubscriptionInvoice::create([
'subscription_id' => $subscription->id,
'business_id' => $business->id,
'invoice_number' => $invoiceNumber,
'type' => 'upgrade',
'amount' => $amountToPay,
'status' => 'pending',
'invoice_date' => now(),
'due_date' => now()->addDays(7),
'line_items' => [
[
'description' => "{$newPlan['name']} (prorated for {$daysLeftInCycle} days)",
'amount' => $proratedCharge,
],
[
'description' => "Credit from {$subscription->plan_name}",
'amount' => -$proratedCredit,
],
],
'payment_method_id' => $subscription->default_payment_method_id,
]);
// Update subscription to new plan immediately
$subscription->update([
'plan_id' => $planId,
'plan_name' => $newPlan['name'],
'plan_price' => $newPlan['price'],
'scheduled_plan_id' => null,
'scheduled_plan_name' => null,
'scheduled_plan_price' => null,
'scheduled_change_date' => null,
]);
// TODO: Charge the payment method for $amountToPay
// TODO: Mark invoice as paid after successful charge
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('success', sprintf(
'Plan upgraded to %s! Invoice %s created for $%s (prorated). New features are active immediately.',
$newPlan['name'],
$invoiceNumber,
number_format($amountToPay, 2)
));
} else {
// DOWNGRADE: Schedule for next billing cycle
$subscription->update([
'scheduled_plan_id' => $planId,
'scheduled_plan_name' => $newPlan['name'],
'scheduled_plan_price' => $newPlan['price'],
'scheduled_change_date' => $subscription->current_period_end,
]);
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('info', sprintf(
'Plan will be downgraded to %s on %s. You\'ll continue to have access to %s features until then.',
$newPlan['name'],
$subscription->current_period_end->format('F j, Y'),
$subscription->plan_name
));
}
}
/**
* Cancel a scheduled plan downgrade.
*/
public function cancelDowngrade(Business $business)
{
$subscription = $business->subscription;
if (! $subscription || ! $subscription->hasScheduledDowngrade()) {
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('error', 'No scheduled downgrade found.');
}
// Cancel the scheduled downgrade
$subscription->update([
'scheduled_plan_id' => null,
'scheduled_plan_name' => null,
'scheduled_plan_price' => null,
'scheduled_change_date' => null,
]);
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('success', 'Scheduled plan downgrade has been cancelled. You will remain on your current plan.');
}
/**
* Display the notification preferences page.
*/
@@ -108,6 +540,65 @@ class SettingsController extends Controller
return view('seller.settings.notifications', compact('business'));
}
/**
* Update the notification settings.
*
* EMAIL NOTIFICATION RULES DOCUMENTATION:
*
* 1. NEW ORDER EMAIL NOTIFICATIONS (new_order_email_notifications)
* Base: Email these addresses when a new order is placed
* - If 'new_order_only_when_no_sales_rep' checked: ONLY send if buyer has NO sales rep assigned
* - If 'new_order_do_not_send_to_admins' checked: Do NOT send to company admins (only to these addresses)
*
* 2. ORDER ACCEPTED EMAIL NOTIFICATIONS (order_accepted_email_notifications)
* Base: Email these addresses when an order is accepted
* - If 'enable_shipped_emails_for_sales_reps' checked: Sales reps assigned to customer get email when order marked Shipped
*
* 3. PLATFORM INQUIRY EMAIL NOTIFICATIONS (platform_inquiry_email_notifications)
* Base: Email these addresses for inquiries
* - Sales reps associated with customer ALWAYS receive email
* - If field is blank AND no sales reps exist: company admins receive notifications
*
* 4. MANUAL ORDER EMAIL NOTIFICATIONS
* - If 'enable_manual_order_email_notifications' checked: Send same emails for manual orders as buyer-created orders
* - If 'enable_manual_order_email_notifications' unchecked: Only send for buyer-created orders
* - If 'manual_order_emails_internal_only' checked: Send manual order emails to internal recipients only (not buyers)
*
* 5. LOW INVENTORY EMAIL NOTIFICATIONS (low_inventory_email_notifications)
* Base: Email these addresses when inventory is low
*
* 6. CERTIFIED SELLER STATUS EMAIL NOTIFICATIONS (certified_seller_status_email_notifications)
* Base: Email these addresses when seller status changes
*/
public function updateNotifications(Business $business, Request $request)
{
$validated = $request->validate([
'new_order_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'new_order_only_when_no_sales_rep' => 'nullable|boolean',
'new_order_do_not_send_to_admins' => 'nullable|boolean',
'order_accepted_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'enable_shipped_emails_for_sales_reps' => 'nullable|boolean',
'platform_inquiry_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'enable_manual_order_email_notifications' => 'nullable|boolean',
'manual_order_emails_internal_only' => 'nullable|boolean',
'low_inventory_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'certified_seller_status_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
]);
// Convert checkbox values (null means unchecked)
$validated['new_order_only_when_no_sales_rep'] = $request->has('new_order_only_when_no_sales_rep');
$validated['new_order_do_not_send_to_admins'] = $request->has('new_order_do_not_send_to_admins');
$validated['enable_shipped_emails_for_sales_reps'] = $request->has('enable_shipped_emails_for_sales_reps');
$validated['enable_manual_order_email_notifications'] = $request->has('enable_manual_order_email_notifications');
$validated['manual_order_emails_internal_only'] = $request->has('manual_order_emails_internal_only');
$business->update($validated);
return redirect()
->route('seller.business.settings.notifications', $business->slug)
->with('success', 'Notification settings updated successfully!');
}
/**
* Display the report settings page.
*/
@@ -115,4 +606,156 @@ class SettingsController extends Controller
{
return view('seller.settings.reports', compact('business'));
}
/**
* Display the integrations page.
*/
public function integrations(Business $business)
{
return view('seller.settings.integrations', compact('business'));
}
/**
* Display the webhooks / API page.
*/
public function webhooks(Business $business)
{
return view('seller.settings.webhooks', compact('business'));
}
/**
* Display the audit logs page.
*/
public function auditLogs(Business $business, Request $request)
{
// CRITICAL: Only show audit logs for THIS business (multi-tenancy)
$query = \App\Models\AuditLog::forBusiness($business->id)
->with(['user', 'auditable']);
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('event', 'like', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search) {
$userQuery->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
// Filter by event type
if ($request->filled('event')) {
$query->byEvent($request->event);
}
// Filter by auditable type (resource type)
if ($request->filled('type')) {
$query->byType($request->type);
}
// Filter by user
if ($request->filled('user_id')) {
$query->forUser($request->user_id);
}
// Filter by date range
if ($request->filled('start_date')) {
$query->where('created_at', '>=', $request->start_date);
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', $request->end_date . ' 23:59:59');
}
// Get paginated results, ordered by most recent first
$audits = $query->latest('created_at')->paginate(50);
// Get unique event types for filter dropdown
$eventTypes = \App\Models\AuditLog::forBusiness($business->id)
->select('event')
->distinct()
->pluck('event')
->sort();
// Get unique auditable types for filter dropdown
$auditableTypes = \App\Models\AuditLog::forBusiness($business->id)
->select('auditable_type')
->whereNotNull('auditable_type')
->distinct()
->get()
->map(function ($log) {
$parts = explode('\\', $log->auditable_type);
return end($parts);
})
->unique()
->sort();
return view('seller.settings.audit-logs', compact('business', 'audits', 'eventTypes', 'auditableTypes'));
}
/**
* View an invoice.
*/
public function viewInvoice(Business $business, string $invoiceId)
{
// TODO: Fetch actual invoice from database
$invoice = [
'id' => $invoiceId,
'date' => now()->subDays(rand(1, 90)),
'amount' => 395.00,
'status' => 'paid',
'items' => [
['description' => 'Marketplace Business Plan', 'quantity' => 1, 'price' => 395.00],
],
];
return view('seller.settings.invoice-view', compact('business', 'invoice'));
}
/**
* Download an invoice as PDF.
*/
public function downloadInvoice(Business $business, string $invoiceId)
{
// TODO: Generate actual PDF from invoice data
// For now, return a mock PDF
$invoice = [
'id' => $invoiceId,
'date' => now()->subDays(rand(1, 90)),
'amount' => 395.00,
'status' => 'paid',
'business_name' => $business->name,
'business_address' => $business->physical_address,
];
// Generate a simple mock PDF content
$pdfContent = "INVOICE #{$invoice['id']}\n\n";
$pdfContent .= "Date: {$invoice['date']->format('m/d/Y')}\n";
$pdfContent .= "Business: {$invoice['business_name']}\n";
$pdfContent .= "Amount: $".number_format($invoice['amount'], 2)."\n";
$pdfContent .= "Status: ".strtoupper($invoice['status'])."\n\n";
$pdfContent .= "This is a mock invoice for testing purposes.\n";
$pdfContent .= "In production, this would be a properly formatted PDF.\n";
return response($pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="invoice-'.$invoiceId.'.pdf"',
]);
}
/**
* Switch the current view (sales, manufacturing, compliance).
*/
public function switchView(Request $request)
{
$validated = $request->validate([
'view' => 'required|in:sales,manufacturing,compliance',
]);
session(['current_view' => $validated['view']]);
return redirect()->back()->with('success', 'View switched successfully');
}
}

View File

@@ -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');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\BusinessHelper;
use Illuminate\Http\Request;
class ViewSwitcherController extends Controller
{
/**
* Switch the active view (sales/manufacturing/compliance) for the current session
*/
public function switch(Request $request)
{
$view = $request->input('view');
// Validate view
if (! in_array($view, ['sales', 'manufacturing', 'compliance'])) {
return back()->with('error', 'Invalid view selected');
}
$business = BusinessHelper::current();
if (! $business) {
return back()->with('error', 'No business context');
}
// Check if business has access to this view
if ($view === 'manufacturing' && ! $business->has_manufacturing) {
return back()->with('error', 'Manufacturing module not enabled for this business');
}
if ($view === 'compliance' && ! $business->has_compliance) {
return back()->with('error', 'Compliance module not enabled for this business');
}
// Store selected view in session
session(['current_view' => $view]);
$viewNames = [
'sales' => 'Sales',
'manufacturing' => 'Manufacturing',
'compliance' => 'Compliance',
];
return back()->with('success', 'Switched to '.$viewNames[$view].' view');
}
/**
* Get the currently selected view
*/
public static function getCurrentView(): string
{
return session('current_view', 'sales');
}
}

70
app/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use OwenIt\Auditing\Models\Audit;
/**
* AuditLog Model
*
* Wrapper around the Laravel Auditing package's Audit model
* with business-specific scopes and relationships for multi-tenancy.
*/
class AuditLog extends Audit
{
/**
* Scope to filter audits for a specific business
*
* Since the audits table doesn't have a business_id column,
* we filter by the auditable models that belong to the business.
* For now, we'll show all audits - this can be refined later
* when implementing proper multi-tenant audit filtering.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $businessId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeForBusiness($query, $businessId)
{
// TODO: Implement proper business-scoped filtering
// This would require joining with auditable models to check business ownership
// For now, return all audits (will be implemented when audit system is fully configured)
return $query;
}
/**
* Scope to filter by event type
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $event
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByEvent($query, $event)
{
return $query->where('event', $event);
}
/**
* Scope to filter by auditable type (resource type)
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $type
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByType($query, $type)
{
return $query->where('auditable_type', $type);
}
/**
* Scope to filter audits for a specific user
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $userId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeForUser($query, $userId)
{
return $query->where('user_id', $userId);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ComponentCategory extends Model
{
use HasFactory;
protected $fillable = [
'business_id',
'name',
'description',
'slug',
'sort_order',
'parent_id',
'is_active',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function components(): HasMany
{
return $this->hasMany(Component::class, 'component_category_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(ComponentCategory::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(ComponentCategory::class, 'parent_id');
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ProductCategory extends Model
{
use HasFactory;
protected $fillable = [
'business_id',
'name',
'description',
'slug',
'sort_order',
'parent_id',
'is_active',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function products(): HasMany
{
return $this->hasMany(Product::class, 'product_category_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(ProductCategory::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(ProductCategory::class, 'parent_id');
}
}

View File

@@ -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

View File

@@ -6,6 +6,7 @@ namespace App\Models;
use Database\Factories\UserFactory;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -19,7 +20,26 @@ use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, HasRoles, Impersonate, Notifiable;
use HasFactory, HasRoles, HasUuids, Impersonate, Notifiable;
/**
* Get the columns that should receive a unique identifier.
*/
public function uniqueIds(): array
{
return ['uuid'];
}
/**
* Generate a new UUID for the model (first 16 hex chars = 18 total with hyphens).
*/
public function newUniqueId(): string
{
$fullUuid = (string) \Illuminate\Support\Str::uuid();
// Return first 16 hex characters (8+4+4): 550e8400-e29b-41d4
return substr($fullUuid, 0, 18); // 18 chars total (16 hex + 2 hyphens)
}
/**
* User type constants

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Services;
/**
* Permission Service
*
* Handles role templates and permission management for businesses.
*/
class PermissionService
{
/**
* Get available role templates
*
* @return array
*/
public function getRoleTemplates(): array
{
return [
'admin' => [
'name' => 'Administrator',
'description' => 'Full access to all features and settings',
'permissions' => ['*'],
],
'manager' => [
'name' => 'Manager',
'description' => 'Manage products, orders, and basic settings',
'permissions' => [
'products.view',
'products.create',
'products.edit',
'products.delete',
'orders.view',
'orders.edit',
'inventory.view',
'inventory.edit',
],
],
'sales' => [
'name' => 'Sales Representative',
'description' => 'View and manage orders',
'permissions' => [
'products.view',
'orders.view',
'orders.create',
'orders.edit',
],
],
'viewer' => [
'name' => 'Viewer',
'description' => 'View-only access to products and orders',
'permissions' => [
'products.view',
'orders.view',
'inventory.view',
],
],
];
}
/**
* Get permissions organized by category
*
* @return array
*/
public function getPermissionsByCategory(): array
{
return [
'products' => [
'name' => 'Products',
'icon' => 'lucide--package',
'permissions' => [
'products.view' => [
'name' => 'View Products',
'description' => 'View product catalog and details',
],
'products.create' => [
'name' => 'Create Products',
'description' => 'Add new products to the catalog',
],
'products.edit' => [
'name' => 'Edit Products',
'description' => 'Modify existing product information',
],
'products.delete' => [
'name' => 'Delete Products',
'description' => 'Remove products from the catalog',
],
],
],
'orders' => [
'name' => 'Orders',
'icon' => 'lucide--shopping-cart',
'permissions' => [
'orders.view' => [
'name' => 'View Orders',
'description' => 'View order history and details',
],
'orders.create' => [
'name' => 'Create Orders',
'description' => 'Place new orders',
],
'orders.edit' => [
'name' => 'Edit Orders',
'description' => 'Modify existing orders',
],
'orders.delete' => [
'name' => 'Cancel Orders',
'description' => 'Cancel or delete orders',
],
],
],
'inventory' => [
'name' => 'Inventory',
'icon' => 'lucide--warehouse',
'permissions' => [
'inventory.view' => [
'name' => 'View Inventory',
'description' => 'View inventory levels and locations',
],
'inventory.edit' => [
'name' => 'Edit Inventory',
'description' => 'Adjust inventory quantities',
],
'inventory.transfer' => [
'name' => 'Transfer Inventory',
'description' => 'Move inventory between locations',
],
],
],
'customers' => [
'name' => 'Customers',
'icon' => 'lucide--users',
'permissions' => [
'customers.view' => [
'name' => 'View Customers',
'description' => 'View customer profiles and information',
],
'customers.create' => [
'name' => 'Create Customers',
'description' => 'Add new customer accounts',
],
'customers.edit' => [
'name' => 'Edit Customers',
'description' => 'Modify customer information',
],
],
],
'settings' => [
'name' => 'Settings',
'icon' => 'lucide--settings',
'permissions' => [
'settings.view' => [
'name' => 'View Settings',
'description' => 'View business settings and configuration',
],
'settings.edit' => [
'name' => 'Edit Settings',
'description' => 'Modify business settings',
],
'users.manage' => [
'name' => 'Manage Users',
'description' => 'Add, edit, and remove user accounts',
],
'billing.manage' => [
'name' => 'Manage Billing',
'description' => 'View and manage billing information',
],
'audit-logs.view' => [
'name' => 'View Audit Logs',
'description' => 'Access audit logs and activity history',
],
],
],
];
}
/**
* Get all available permissions (flattened)
*
* @return array
*/
public function getAllPermissions(): array
{
$permissions = [];
foreach ($this->getPermissionsByCategory() as $category => $categoryData) {
$permissions = array_merge($permissions, array_keys($categoryData['permissions']));
}
return $permissions;
}
/**
* Check if a user has a specific permission
*
* @param \App\Models\User $user
* @param string $permission
* @return bool
*/
public function hasPermission($user, string $permission): bool
{
// If user has wildcard permission, grant access to everything
if (is_array($user->permissions) && in_array('*', $user->permissions)) {
return true;
}
// Check if user has the specific permission
return is_array($user->permissions) && in_array($permission, $user->permissions);
}
}

File diff suppressed because one or more lines are too long

127
check-settings.sh Normal file
View File

@@ -0,0 +1,127 @@
#!/bin/bash
# Settings Routes and Views Validation Script
# Run this script to verify all settings pages are properly configured
echo "======================================"
echo "Settings Routes & Views Validation"
echo "======================================"
echo ""
ERRORS=0
# Expected routes from routes/seller.php (within settings prefix)
declare -a EXPECTED_ROUTES=(
"company-information"
"users"
"sales-config"
"orders"
"invoices"
"brand-kit"
"payments"
"manage-licenses"
"plans-and-billing"
"notifications"
"reports"
"integrations"
"webhooks"
"audit-logs"
"profile"
)
# Expected view files
declare -a EXPECTED_VIEWS=(
"company-information.blade.php"
"users.blade.php"
"users-edit.blade.php"
"sales-config.blade.php"
"invoices.blade.php"
"brand-kit.blade.php"
"payments.blade.php"
"manage-licenses.blade.php"
"plans-and-billing.blade.php"
"notifications.blade.php"
"reports.blade.php"
"integrations.blade.php"
"webhooks.blade.php"
"audit-logs.blade.php"
"profile.blade.php"
)
# Expected controller methods
declare -a EXPECTED_METHODS=(
"companyInformation"
"updateCompanyInformation"
"users"
"editUser"
"inviteUser"
"updateUser"
"removeUser"
"salesConfig"
"updateSalesConfig"
"invoices"
"brandKit"
"payments"
"manageLicenses"
"plansAndBilling"
"changePlan"
"notifications"
"updateNotifications"
"reports"
"integrations"
"webhooks"
"auditLogs"
"profile"
"updateProfile"
"updatePassword"
)
echo "Checking Routes..."
echo "------------------"
for route in "${EXPECTED_ROUTES[@]}"; do
if grep -q "name('$route')" routes/seller.php; then
echo "✓ Route: $route"
else
echo "✗ MISSING Route: $route"
((ERRORS++))
fi
done
echo ""
echo "Checking Views..."
echo "------------------"
for view in "${EXPECTED_VIEWS[@]}"; do
if [ -f "resources/views/seller/settings/$view" ]; then
SIZE=$(stat -c%s "resources/views/seller/settings/$view" 2>/dev/null || stat -f%z "resources/views/seller/settings/$view" 2>/dev/null)
if [ "$SIZE" -gt 100 ]; then
echo "✓ View: $view ($SIZE bytes)"
else
echo "⚠ View exists but seems empty: $view ($SIZE bytes)"
((ERRORS++))
fi
else
echo "✗ MISSING View: $view"
((ERRORS++))
fi
done
echo ""
echo "Checking Controller Methods..."
echo "------------------------------"
for method in "${EXPECTED_METHODS[@]}"; do
if grep -q "function $method" app/Http/Controllers/Seller/SettingsController.php; then
echo "✓ Method: $method"
else
echo "✗ MISSING Method: $method"
((ERRORS++))
fi
done
echo ""
echo "======================================"
if [ $ERRORS -eq 0 ]; then
echo "✓ All checks passed! No issues found."
exit 0
else
echo "✗ Found $ERRORS issue(s) that need attention."
exit 1
fi

View File

@@ -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!');
}

View File

@@ -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";
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('component_categories', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained('businesses')->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('component_categories')->nullOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->boolean('public')->default(true);
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->index('business_id');
$table->index('parent_id');
$table->index('public');
$table->index('sort_order');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('component_categories');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('product_categories', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
// Indexes for performance
$table->index(['business_id', 'is_active']);
$table->index('name');
// Unique constraint: prevent duplicate category names per business
$table->unique(['business_id', 'name']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('product_categories');
}
};

View File

@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('businesses', function (Blueprint $table) {
$table->boolean('has_manufacturing')->default(false)->after('status');
$table->boolean('has_compliance')->default(false)->after('has_manufacturing');
$table->boolean('has_marketing')->default(false)->after('has_compliance');
$table->boolean('has_analytics')->default(false)->after('has_marketing');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('businesses', function (Blueprint $table) {
$table->dropColumn([
'has_manufacturing',
'has_compliance',
'has_marketing',
'has_analytics',
]);
});
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Add UUID column (16 hex chars = 18 total with hyphens)
$table->char('uuid', 18)->nullable()->after('id'); // Format: 550e8400-e29b-41d4
$table->unique('uuid');
$table->index('uuid');
});
// Generate UUIDs for existing users (first 16 hex chars)
$users = DB::table('users')->get();
foreach ($users as $user) {
$fullUuid = (string) Str::uuid();
// Extract first 16 hex characters (8+4+4): 550e8400-e29b-41d4
$shortUuid = substr($fullUuid, 0, 18); // 18 chars total (16 hex + 2 hyphens)
DB::table('users')
->where('id', $user->id)
->update(['uuid' => $shortUuid]);
}
// Make UUID non-nullable after populating existing records
Schema::table('users', function (Blueprint $table) {
$table->char('uuid', 18)->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['uuid']);
$table->dropIndex(['uuid']);
$table->dropColumn('uuid');
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->foreignId('product_category_id')->nullable()->after('category')->constrained('product_categories')->nullOnDelete();
$table->index('product_category_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropForeign(['product_category_id']);
$table->dropIndex(['product_category_id']);
$table->dropColumn('product_category_id');
});
}
};

View File

@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('product_categories', function (Blueprint $table) {
$table->string('slug')->nullable()->after('name');
$table->integer('sort_order')->nullable()->after('description');
$table->foreignId('parent_id')->nullable()->after('sort_order')->constrained('product_categories')->nullOnDelete();
$table->index('parent_id');
$table->index('slug');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('product_categories', function (Blueprint $table) {
$table->dropForeign(['parent_id']);
$table->dropIndex(['parent_id']);
$table->dropIndex(['slug']);
$table->dropColumn(['slug', 'sort_order', 'parent_id']);
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('components', function (Blueprint $table) {
$table->foreignId('component_category_id')->nullable()->after('business_id')->constrained('component_categories')->nullOnDelete();
$table->index('component_category_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('components', function (Blueprint $table) {
$table->dropForeign(['component_category_id']);
$table->dropIndex(['component_category_id']);
$table->dropColumn('component_category_id');
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('component_categories', function (Blueprint $table) {
$table->boolean('is_active')->default(true)->after('description');
$table->string('slug')->nullable()->after('name');
$table->dropColumn('public');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('component_categories', function (Blueprint $table) {
$table->dropColumn(['is_active', 'slug']);
$table->boolean('public')->default(true);
});
}
};

408
docs/ROUTE_ISOLATION.md Normal file
View File

@@ -0,0 +1,408 @@
# Route Isolation & Module Architecture
## Overview
The application has three completely isolated route areas to prevent development collisions and maintain clear separation of concerns.
## Route Areas
### 1. Superadmin Platform (`/admin`)
- **Framework**: Filament v3
- **Users**: Platform superadmins
- **Scope**: Platform-wide (manages all businesses, users, and system configuration)
- **Routing**: Auto-generated by Filament Resources
- **Isolation**: Each Resource is a separate file
**Filament Resources:**
- UserResource - Platform user management
- BusinessResource - All businesses (buyers + sellers)
- BusinessModuleResource - Enable/disable modules per business
- BrandResource - Brand management
- ProductResource - Product management
- ComponentResource - Component management
- OrderResource - Platform-wide order oversight
- InvoiceResource - Platform-wide invoice oversight
- ModuleResource - Module configuration
- Batches/BatchResource - Manufacturing batch oversight
**Development Pattern:**
- No route collision risk (Filament auto-generates routes)
- Developers work on separate Resource files
- Already isolated by design
---
### 2. Buyer/Dispensary Routes (`/b/{business}/*`)
- **Framework**: Blade + DaisyUI + Tailwind
- **Users**: Dispensary owners, purchasing managers, staff
- **Scope**: Business-scoped (multi-tenant)
- **Routing**: Custom Laravel route groups
**Route Structure:**
```
/b/ # User-level (NO business context)
/register # Registration flow
/profile # Personal profile
/settings # Personal settings
/dashboard # Marketplace browsing
/browse # Product marketplace
/brands # Brand directory
/b/{business}/ # Business-scoped routes
/cart # Shopping cart
/checkout # Checkout process
/orders # Order management
/invoices # Invoice management
/favorites # Favorites/wishlists
/settings/* # Business settings (MODULE)
```
**Buyer Settings Module** (`/b/{business}/settings/*`):
- Always enabled (required module)
- Access controlled via role-based permissions
- Features: Profile, locations, contacts, team management
---
### 3. Seller/Brand Routes (`/s/{business}/*`)
- **Framework**: Blade + DaisyUI + Tailwind
- **Users**: Brand owners, production managers, sales staff
- **Scope**: Business-scoped (multi-tenant)
- **Routing**: Custom Laravel route groups with module isolation
**Route Structure:**
```
/s/ # User-level (NO business context)
/register # Registration flow
/profile # Personal profile
/settings # Personal settings
/dashboard # Main dashboard
/setup # Business onboarding wizard
/s/{business}/ # Business-scoped routes (CORE - always enabled)
/dashboard # Business dashboard
/orders # Order fulfillment
/invoices # Invoice management
/customers # CRM
/products # Product catalog
/components # Component management
/locations # Location management
/contacts # Contact management
/fleet/drivers # Driver management
/fleet/vehicles # Vehicle management
/s/{business}/manufacturing/* # OPTIONAL MODULE
/s/{business}/compliance/* # OPTIONAL MODULE
/s/{business}/marketing/* # OPTIONAL MODULE
/s/{business}/analytics/* # OPTIONAL MODULE
/s/{business}/settings/* # REQUIRED MODULE
```
---
## Core Platform vs. Modules
### Core B2B Platform (NOT a module - base application)
The foundation of Cannabrands B2B marketplace:
**Buyer Side (`/b/{business}/*`):**
- Product browsing and marketplace
- Shopping cart and checkout
- Order management
- Invoice tracking
- Basic analytics (order history, purchase trends)
**Seller Side (`/s/{business}/*`):**
- Product catalog management
- Order fulfillment
- Invoice generation
- Customer management (CRM)
- Fleet management (drivers, vehicles)
- **Core analytics** (sales dashboards, order reports, product performance)
**Core Analytics** (built into platform, no module flag needed):
- Sales dashboards with revenue trends
- Order history charts and filtering
- Product performance metrics
- Customer analytics and trends
- Basic KPIs on main dashboard
- Standard report exports
**Permissions for core analytics:**
- `view-dashboard` - See basic sales dashboard
- `view-order-reports` - See order history charts
- `view-sales-metrics` - See revenue/sales KPIs
- `export-order-data` - Export order reports
---
## Module Types
### Optional Modules (Feature Flags)
Enabled/disabled per business via database flags in `businesses` table.
| Module | Route Prefix | Flag | Purpose |
|--------|-------------|------|---------|
| **Manufacturing** | `/manufacturing/*` | `has_manufacturing` | Production tracking, batches, wash reports, conversions, BOMs |
| **Compliance** | `/compliance/*` | `has_compliance` | Regulatory tracking, METRC integration, lab results, quarantine |
| **Marketing** | `/marketing/*` | `has_marketing` | Social media, campaigns, email marketing, promotions, segments |
| **Analytics** | `/analytics/*` | `has_analytics` | **Advanced BI**: Cross-module reporting, custom report builder, executive dashboards, data exports/API |
**Access Control:**
- Middleware checks business flag: `if (!$business->has_manufacturing) abort(404)`
- Only show menu items when module enabled
- Can be enabled/disabled via `/admin` by superadmins
**Analytics Module Permissions** (requires `has_analytics` flag):
- `analytics.view-advanced-reports` - Access Analytics module
- `analytics.create-custom-reports` - Build custom reports
- `analytics.view-cross-module` - See combined manufacturing+sales+marketing data
- `analytics.export-bi-data` - Export BI datasets
- `analytics.manage-dashboards` - Configure executive dashboards
---
### Required Modules (Role-Based Permissions)
Always enabled, access controlled via Spatie permissions.
| Module | Route Prefix | Access Control | Purpose |
|--------|-------------|----------------|---------|
| **Settings** | `/settings/*` | Roles/Permissions | Company info, team management, billing, licenses, integrations |
**Seller Settings Routes:**
```
/s/{business}/settings/
/company-information # Company details
/users # Team management
/orders # Order settings
/brands # Brand settings
/payments # Payment configuration
/invoices # Invoice settings
/manage-licenses # License management
/plans-and-billing # Subscription & billing
/notifications # Notification preferences
/reports # Report configuration
```
**Buyer Settings Routes:**
```
/b/{business}/settings/
/profile # Business profile
/locations # Location management
/contacts # Contact management
/users # Team management
```
**Access Control:**
- No feature flag (always available)
- Controlled via Spatie permissions: `manage-settings`, `view-billing`, `manage-team`, etc.
- Different roles see different settings sections
---
## Core Analytics vs. Analytics Module
### When to Use Core Analytics (Built-in)
Use the built-in analytics features in the core platform when you need:
- Standard sales dashboards
- Order history and trends
- Product performance metrics
- Customer purchase analytics
- Basic reporting and exports
**No module flag required** - Available to all businesses by default
**Access controlled via core permissions** - `view-dashboard`, `view-sales-metrics`, etc.
### When to Use Analytics Module (Optional)
Enable the Analytics module (`has_analytics` flag) when you need:
- **Cross-module reporting**: Combine data from sales, manufacturing, marketing, and compliance
- **Custom report builder**: Create ad-hoc reports with drag-and-drop
- **Executive dashboards**: High-level KPIs across entire business
- **Advanced visualizations**: Custom charts, heatmaps, forecasting
- **Data exports/API**: Integrate with external BI tools (Tableau, PowerBI)
- **Manufacturing analytics**: Track batch yields, production efficiency, waste analysis
- **Marketing ROI**: Track campaign performance, customer acquisition costs
**Example:**
- Core sales dashboard shows: "You sold 100 units this month for $10,000 revenue"
- Analytics module shows: "Your marketing campaign cost $500, brought 50 new customers, who bought products made from 3 batches with 92% average yield, generating $6,000 profit after COGS"
---
## Development Workflow
### Parallel Development Without Collisions
**Scenario:** Three developers working simultaneously on seller features.
**Developer A (Sales Team):**
- Works on: `/s/{business}/orders/*`, `/s/{business}/invoices/*`
- Files: `routes/seller.php` (lines 126-163), `OrderController.php`, `InvoiceController.php`
- Branch: `feature/order-enhancements`
**Developer B (Manufacturing Team):**
- Works on: `/s/{business}/manufacturing/*`
- Files: `routes/seller.php` (lines 237-240), `Manufacturing/*Controller.php`
- Branch: `feature/wash-report-enhancements`
**Developer C (Settings Team):**
- Works on: `/s/{business}/settings/*`
- Files: `routes/seller.php` (lines 269-281), `Seller/SettingsController.php`
- Branch: `feature/billing-integration`
**Result:** Zero route collisions! Each developer works in isolated route namespace.
---
## Migration: Module Flags
**File:** `database/migrations/2025_11_12_180802_add_module_flags_to_businesses_table.php`
Adds optional module flags to `businesses` table:
```php
$table->boolean('has_manufacturing')->default(false);
$table->boolean('has_compliance')->default(false);
$table->boolean('has_marketing')->default(false);
$table->boolean('has_analytics')->default(false);
```
**Usage in Controllers:**
```php
// Check if business has manufacturing module enabled
if (!$business->has_manufacturing) {
abort(404, 'Manufacturing module not enabled for this business');
}
```
**Usage in Blade:**
```blade
@if($business->has_manufacturing)
<a href="{{ route('seller.business.manufacturing.batches.index', $business) }}">
Manufacturing
</a>
@endif
```
---
## Route Naming Convention
All routes use dot notation with prefixes:
**Admin:**
- `admin.*` - Auto-generated by Filament
**Buyer:**
- `buyer.*` - User-level routes (no business context)
- `buyer.business.*` - Business-scoped routes
**Seller:**
- `seller.*` - User-level routes (no business context)
- `seller.business.*` - Business-scoped routes
- `seller.business.manufacturing.*` - Manufacturing module
- `seller.business.compliance.*` - Compliance module
- `seller.business.marketing.*` - Marketing module
- `seller.business.analytics.*` - Analytics module
- `seller.business.settings.*` - Settings module
**Example:**
```php
route('seller.business.manufacturing.wash-reports.show', [$business, $washReport])
// Generates: /s/{business-slug}/manufacturing/wash-reports/WR-123
```
---
## Module Isolation Benefits
### 1. Parallel Development
- Multiple teams work simultaneously without conflicts
- Each module has isolated route namespace
- Merge conflicts limited to route file only (easy to resolve)
### 2. Feature Enablement
- Businesses only pay for modules they need
- Module flags control availability per business
- Clean separation between core and optional features
### 3. Permission Management
- Role-based access within each module
- Fine-grained control: user can access orders but not billing
- Consistent permission naming: `manufacturing.view-batches`, `settings.manage-billing`
### 4. Code Organization
- Clear boundaries between functional areas
- Easy to locate controllers, views, tests for specific features
- New developers quickly understand structure
### 5. Testing Isolation
- Test suite can run module tests independently
- Feature tests scoped to module routes
- Easier to mock module availability
---
## Adding a New Module
**Example: Adding Inventory module to sellers**
1. **Add feature flag migration:**
```php
Schema::table('businesses', function (Blueprint $table) {
$table->boolean('has_inventory')->default(false);
});
```
2. **Add route group in `routes/seller.php`:**
```php
// Inventory Module (Optional)
// Flag: has_inventory
// Features: Stock tracking, reorder points, warehouse management
Route::prefix('inventory')->name('inventory.')->group(function () {
Route::get('/', [InventoryController::class, 'index'])->name('index');
Route::get('/warehouses', [InventoryController::class, 'warehouses'])->name('warehouses');
// ... more routes
});
```
3. **Create controllers:**
```
app/Http/Controllers/Seller/Inventory/
InventoryController.php
WarehouseController.php
```
4. **Add middleware check in controller:**
```php
public function __construct()
{
$this->middleware(function ($request, $next) {
$business = $request->route('business');
if (!$business->has_inventory) {
abort(404, 'Inventory module not enabled');
}
return $next($request);
});
}
```
5. **Update navigation blade components:**
```blade
@if($business->has_inventory)
<li><a href="{{ route('seller.business.inventory.index', $business) }}">Inventory</a></li>
@endif
```
Done! New module fully isolated from existing code.
---
## Related Documentation
- `docs/URL_STRUCTURE.md` - URL patterns and conventions
- `docs/DATABASE.md` - Database schema
- `CLAUDE.md` - Common mistakes and patterns
- `CONTRIBUTING.md` - Git workflow

246
import-categories.php Normal file
View File

@@ -0,0 +1,246 @@
#!/usr/bin/env php
<?php
/**
* Import Product Categories from MySQL JSON export to PostgreSQL
*
* This script imports categories from categories.json and creates them
* in the product_categories table, associating them with the appropriate business.
*
* Usage:
* Run in artisan tinker:
* php artisan tinker --execute="require 'import-categories.php';"
*/
use App\Models\Business;
use App\Models\ProductCategory;
use App\Models\ComponentCategory;
use Illuminate\Support\Facades\DB;
echo "=== CATEGORY IMPORT SCRIPT ===\n\n";
// Load the JSON data
$jsonFile = base_path('categories-with-type.json');
if (!file_exists($jsonFile)) {
die("ERROR: categories-with-type.json not found in project root!\n");
}
$categoriesJson = file_get_contents($jsonFile);
$mysqlCategories = json_decode($categoriesJson, true);
if (!$mysqlCategories) {
die("ERROR: Failed to parse categories-with-type.json!\n");
}
echo "Loaded " . count($mysqlCategories) . " categories from MySQL export\n\n";
// We need to associate categories with a business
// For CannaB rands, find by slug
echo "Looking for CannaB rands business...\n";
$business = Business::where('slug', 'cannabrands')->first();
if (!$business) {
// Fallback: try to find by name
$business = Business::where('name', 'LIKE', '%canna%brand%')->first();
}
if (!$business) {
die("ERROR: CannaB rands business not found! Please check the business slug or name.\n");
}
echo "✓ Found business: {$business->name} (ID: {$business->id}, Slug: {$business->slug})\n\n";
// Category type mapping based on category names
// We'll categorize them based on their usage in products vs components
$categoryTypeMap = [
// Product categories (based on Hash Factory categories and common cannabis categories)
'Rosin' => 'product',
'Live Rosin' => 'product',
'Live Hash Rosin' => 'product',
'Cured Hash' => 'product',
'Live Hash' => 'product',
'BHO' => 'product',
'Fresh Press' => 'product',
'Cold Cure' => 'product',
'Green Hash' => 'product',
'Blonde Hash' => 'product',
'Cured Rosin' => 'product',
'Live Resin' => 'product',
'Cured Resin' => 'product',
'Distillate' => 'product',
'Shatter' => 'product',
'Wax' => 'product',
'Budder' => 'product',
'Badder' => 'product',
'Crumble' => 'product',
'Sugar' => 'product',
'Diamonds' => 'product',
'Hash' => 'product',
'Kief' => 'product',
'Oil' => 'product',
// Flower categories
'Flower' => 'product',
'Bulk Flower' => 'product',
'Packaged Flower' => 'product',
'Cured Flower' => 'product',
'Fresh Frozen' => 'product',
'Shake' => 'product',
'Trim' => 'product',
'Clones' => 'product',
'Seeds' => 'product',
// Pre-Roll categories
'Pre-Rolls' => 'product',
'Blunts' => 'product',
'Infused' => 'product',
'Non-Infused' => 'product',
// Edibles
'Edibles' => 'product',
'Edibles & Ingestibles' => 'product',
'Gummies' => 'product',
'Chocolate' => 'product',
'Cookies' => 'product',
'Baked Goods (non-cookie)' => 'product',
'Beverages' => 'product',
'Capsules' => 'product',
'Tinctures' => 'product',
// Topicals
'Topicals' => 'product',
'Lotions & Creams' => 'product',
'Balms/Salves' => 'product',
// Cartridges
'Cartridges' => 'product',
'510 Thread' => 'product',
'Disposable' => 'product',
// Component/Packaging categories (anything that's raw materials or packaging)
'Packaging' => 'component',
'Pop Top Pre Roll Child Resistant Tubes' => 'component',
'Concentrate Jar' => 'component',
'Push-Up Tube' => 'component',
'Mylar Bags' => 'component',
'Multipack Pre-Roll Bottle' => 'component',
'Multipack Pre-Roll Lid' => 'component',
'Shrink Bands' => 'component',
'Seals' => 'component',
'Bottles' => 'component',
'Drams' => 'component',
'Boxes' => 'component',
'Pre-Roll Cones' => 'component',
'Treefer 109/26' => 'component',
'Treefer 84/26' => 'component',
'Treefer 70/26' => 'component',
'Hemp Blunts' => 'component',
'Glass Tube - Corks' => 'component',
// Accessories (typically components/materials)
'Accessories' => 'component',
'Apperal' => 'component',
'Batteries' => 'component',
'Bongs' => 'component',
'Grinders' => 'component',
'Lighters' => 'component',
'Pipes' => 'component',
'Rigs' => 'component',
'Rolling Papers & Trays' => 'component',
'Storage' => 'component',
'Vaporizers' => 'component',
// Lab/Testing/Additives
'Lab Testing' => 'component',
'Additives' => 'component',
'Terpenes' => 'component',
// Food ingredients (components for edibles)
'Tea Bags' => 'component',
'Juice' => 'component',
'Puree Mix' => 'component',
'Coffee' => 'component',
// Bulk/Raw materials
'Bulk' => 'component',
'Concentrates' => 'product', // This one could be either, defaulting to product
];
// Stats
$imported = 0;
$skipped = 0;
$errors = [];
echo "Starting import...\n";
echo "================\n\n";
foreach ($mysqlCategories as $mysqlCategory) {
// Use type from JSON to determine which model to use
$type = $mysqlCategory['type'];
// Choose the correct model based on type
$modelClass = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$typeName = $type === 'product' ? 'Product' : 'Component';
// Check if already exists
$existing = $modelClass::where('business_id', $business->id)
->where('name', $mysqlCategory['name'])
->first();
if ($existing) {
echo "⊘ Skipping '{$mysqlCategory['name']}' ({$typeName}, already exists)\n";
$skipped++;
continue;
}
try {
$category = $modelClass::create([
'business_id' => $business->id,
'name' => $mysqlCategory['name'],
'description' => $mysqlCategory['description'],
'is_active' => true,
]);
echo "✓ Imported '{$category->name}' ({$typeName}, ID: {$category->id})\n";
$imported++;
} catch (\Exception $e) {
echo "✗ Error importing '{$mysqlCategory['name']}': {$e->getMessage()}\n";
$errors[] = [
'category' => $mysqlCategory['name'],
'type' => $typeName,
'error' => $e->getMessage(),
];
}
}
echo "\n================\n";
echo "IMPORT COMPLETE\n";
echo "================\n\n";
echo "Summary:\n";
echo " Total categories in file: " . count($mysqlCategories) . "\n";
echo " Successfully imported: {$imported}\n";
echo " Skipped (already exists): {$skipped}\n";
echo " Errors: " . count($errors) . "\n\n";
if (count($errors) > 0) {
echo "Errors:\n";
foreach ($errors as $error) {
echo " - {$error['category']}: {$error['error']}\n";
}
echo "\n";
}
// Show breakdown by type
$productCount = ProductCategory::where('business_id', $business->id)->count();
$componentCount = ComponentCategory::where('business_id', $business->id)->count();
echo "Category breakdown:\n";
echo " Product categories: {$productCount}\n";
echo " Component categories: {$componentCount}\n";
echo " Total: " . ($productCount + $componentCount) . "\n\n";
echo "Next steps:\n";
echo "1. Review categories at: /s/{$business->slug}/categories\n";
echo "2. Create/import products and components using these categories\n";
echo "3. Verify category assignments are correct\n\n";

View File

@@ -0,0 +1,49 @@
@props(['category', 'type', 'business', 'level' => 0])
<tr>
<td>
@if($level > 0)
<span class="text-base-content/40">{{ str_repeat('—', $level) }} </span>
@endif
{{ $category->name }}
</td>
<td class="text-right">
{{ $type === 'product' ? ($category->products_count ?? 0) : ($category->components_count ?? 0) }}
</td>
<td class="text-right">
<div class="flex gap-2 justify-end">
<a href="{{ route('seller.business.settings.categories.create', [$business->slug, $type]) }}?parent={{ $category->id }}"
class="btn btn-xs btn-ghost">
<span class="icon-[lucide--plus] size-3"></span>
Add Sub
</a>
<a href="{{ route('seller.business.settings.categories.edit', [$business->slug, $type, $category->id]) }}"
class="btn btn-xs">
<span class="icon-[lucide--pencil] size-3"></span>
Edit
</a>
@php
$hasItems = $type === 'product' ? ($category->products_count ?? 0) > 0 : ($category->components_count ?? 0) > 0;
$hasChildren = $category->children && $category->children->count() > 0;
@endphp
@if(!$hasItems && !$hasChildren)
<form method="POST"
action="{{ route('seller.business.settings.categories.destroy', [$business->slug, $type, $category->id]) }}"
onsubmit="return confirm('Delete this category?')"
class="inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-xs btn-ghost">
<span class="icon-[lucide--trash-2] size-3"></span>
</button>
</form>
@endif
</div>
</td>
</tr>
@if($category->children && $category->children->count() > 0)
@foreach($category->children as $child)
<x-category-tree-item :category="$child" :type="$type" :level="$level + 1" :business="$business" />
@endforeach
@endif

View File

@@ -0,0 +1,253 @@
{{-- PREVIEW COMPONENT - Seller Account Dropdown --}}
{{-- This is a temporary preview - does not affect production navigation --}}
@php
// Get business from current route parameter (for multi-business context)
$routeBusiness = request()->route('business');
// Fallback to primary business if no route parameter (shouldn't happen in seller context)
$business = $routeBusiness ?? auth()->user()?->primaryBusiness();
$user = auth()->user();
$isOwner = $business && $business->owner_user_id === $user->id;
$isSuperAdmin = $user->user_type === 'admin';
$canManageCompany = $isOwner || $isSuperAdmin;
@endphp
<div x-data="{ accountDropdownOpen: false }" class="relative" x-cloak>
{{-- User Badge (Bottom Left of Sidebar) --}}
<button
@click="accountDropdownOpen = !accountDropdownOpen"
class="flex items-center gap-3 w-full p-3 hover:bg-base-200 transition-colors rounded-lg"
aria-label="Account Menu">
{{-- Avatar --}}
<div class="avatar placeholder">
<div class="bg-primary text-primary-content w-10 rounded-full">
<span class="text-sm font-semibold">
{{ strtoupper(substr($user->first_name ?? 'U', 0, 1)) }}{{ strtoupper(substr($user->last_name ?? 'S', 0, 1)) }}
</span>
</div>
</div>
{{-- User Info --}}
<div class="flex-1 text-left overflow-hidden">
<div class="text-sm font-semibold truncate">{{ trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? '')) ?: 'User' }}</div>
<div class="text-xs text-base-content/60 truncate">
@if($isOwner)
<span class="badge badge-primary badge-xs">Owner</span>
@elseif($isSuperAdmin)
<span class="badge badge-error badge-xs">Super Admin</span>
@else
<span class="text-xs">Team Member</span>
@endif
</div>
</div>
{{-- Chevron --}}
<span class="icon-[lucide--chevron-up] size-4 transition-transform"
:class="accountDropdownOpen ? 'rotate-180' : ''"></span>
</button>
{{-- Dropdown Menu --}}
<div x-show="accountDropdownOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95 translate-y-2"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.away="accountDropdownOpen = false"
class="absolute bottom-full left-0 mb-2 w-72 bg-base-100 rounded-box shadow-xl border border-base-300 z-50">
<div class="p-2">
{{-- MY ACCOUNT Section (All Users) --}}
<div class="mb-2">
<div class="px-3 py-2 text-xs font-semibold text-base-content/60 uppercase tracking-wide">
My Account
</div>
<ul class="menu menu-sm">
{{-- Profile --}}
<li>
<a href="{{ $business ? route('seller.business.settings.profile', $business->slug) : '#' }}" class="flex items-center gap-3">
<span class="icon-[lucide--user] size-4"></span>
<span>Profile</span>
</a>
</li>
{{-- Notifications --}}
<li>
<a href="{{ $business ? route('seller.business.settings.notifications', $business->slug) : '#' }}"
class="flex items-center gap-3">
<span class="icon-[lucide--bell] size-4"></span>
<span>Notifications</span>
</a>
</li>
{{-- API Keys / Password (Owner Only) --}}
@if($canManageCompany)
<li>
<a href="#" class="flex items-center gap-3">
<span class="icon-[lucide--key] size-4"></span>
<span>API Keys / Password</span>
<span class="ml-auto badge badge-xs badge-warning">Coming Soon</span>
</a>
</li>
@else
<li class="disabled">
<div class="flex items-center gap-3 opacity-50 cursor-not-allowed">
<span class="icon-[lucide--lock] size-4 text-base-content/40"></span>
<span class="icon-[lucide--key] size-4 text-base-content/40"></span>
<span class="text-base-content/40">API Keys / Password</span>
</div>
</li>
@endif
</ul>
</div>
{{-- MY COMPANY Section (Owner/Admin Only) --}}
@if($canManageCompany && $business)
<div class="divider my-1"></div>
<div class="mb-2">
<div class="px-3 py-2 text-xs font-semibold text-base-content/60 uppercase tracking-wide">
My Company
</div>
<ul class="menu menu-sm">
{{-- Company Info --}}
<li>
<a href="{{ route('seller.business.settings.company-information', $business->slug) }}"
class="flex items-center gap-3">
<span class="icon-[lucide--building-2] size-4"></span>
<span>Company Info</span>
</a>
</li>
{{-- Users & Roles --}}
<li>
<a href="{{ route('seller.business.settings.users', $business->slug) }}"
class="flex items-center gap-3">
<span class="icon-[lucide--users] size-4"></span>
<span>Users & Roles</span>
</a>
</li>
{{-- Licenses / Compliance --}}
<li>
<a href="{{ route('seller.business.settings.manage-licenses', $business->slug) }}"
class="flex items-center gap-3">
<span class="icon-[lucide--file-check] size-4"></span>
<span>Licenses / Compliance</span>
</a>
</li>
{{-- Payments & Subscriptions --}}
<li>
<a href="{{ route('seller.business.settings.plans-and-billing', $business->slug) }}"
class="flex items-center gap-3">
<span class="icon-[lucide--credit-card] size-4"></span>
<span>Payments & Subscriptions</span>
</a>
</li>
{{-- Integrations --}}
<li>
<a href="{{ route('seller.business.settings.integrations', $business->slug) }}" class="flex items-center gap-3">
<span class="icon-[lucide--plug] size-4"></span>
<span>Integrations</span>
</a>
</li>
</ul>
</div>
@endif
{{-- ADMIN CONSOLE Section (Owner/Admin Only) --}}
@if($canManageCompany && $business)
<div class="divider my-1"></div>
<div class="mb-2">
<div class="px-3 py-2 text-xs font-semibold text-base-content/60 uppercase tracking-wide">
Admin Console
</div>
<ul class="menu menu-sm">
{{-- Sales Config --}}
<li>
<a href="{{ route('seller.business.settings.sales-config', $business->slug) }}" class="flex items-center gap-3">
<span class="icon-[lucide--shopping-cart] size-4"></span>
<span>Sales Config</span>
</a>
</li>
{{-- Inventory/Manufacturing Config --}}
<li>
<a href="#" class="flex items-center gap-3">
<span class="icon-[lucide--factory] size-4"></span>
<span>Manufacturing Config</span>
<span class="ml-auto badge badge-xs badge-warning">Coming Soon</span>
</a>
</li>
{{-- Categories --}}
<li>
<a href="{{ route('seller.business.settings.categories.index', $business->slug) }}" class="flex items-center gap-3">
<span class="icon-[lucide--folder-tree] size-4"></span>
<span>Categories</span>
</a>
</li>
{{-- Messaging Providers --}}
<li>
<a href="#" class="flex items-center gap-3">
<span class="icon-[lucide--mail] size-4"></span>
<span>Messaging Providers</span>
<span class="ml-auto badge badge-xs badge-warning">Coming Soon</span>
</a>
</li>
{{-- Brand Kits & Assets --}}
<li>
<a href="{{ route('seller.business.settings.brand-kit', $business->slug) }}"
class="flex items-center gap-3">
<span class="icon-[lucide--palette] size-4"></span>
<span>Brand Kits & Assets</span>
</a>
</li>
{{-- Webhooks / API --}}
<li>
<a href="{{ route('seller.business.settings.webhooks', $business->slug) }}" class="flex items-center gap-3">
<span class="icon-[lucide--webhook] size-4"></span>
<span>Webhooks / API</span>
</a>
</li>
{{-- Audit Logs --}}
<li>
<a href="{{ route('seller.business.settings.audit-logs', $business->slug) }}" class="flex items-center gap-3">
<span class="icon-[lucide--file-text] size-4"></span>
<span>Audit Logs</span>
</a>
</li>
{{-- Reports --}}
<li>
<a href="{{ route('seller.business.settings.reports', $business->slug) }}" class="flex items-center gap-3">
<span class="icon-[lucide--bar-chart] size-4"></span>
<span>Reports</span>
</a>
</li>
</ul>
</div>
@endif
{{-- Sign Out --}}
<div class="divider my-1"></div>
<ul class="menu menu-sm">
<li>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="flex items-center gap-3 w-full text-error">
<span class="icon-[lucide--log-out] size-4"></span>
<span>Sign Out</span>
</button>
</form>
</li>
</ul>
</div>
</div>
</div>

View File

@@ -351,11 +351,16 @@
</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>
</div>
<div class="mb-2">
<div class="sticky bottom-0 bg-base-100 pb-2 z-10">
<hr class="border-base-300 my-2 border-dashed" />
<!-- User Account Section -->
<div class="px-2 mb-3">
<x-seller-account-dropdown />
</div>
<!-- Version Info Section -->
<div class="mx-2 mb-3 px-3 text-xs text-center text-base-content/50">
<div class="mx-2 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">
@if($appVersion === 'dev')
@@ -364,54 +369,7 @@
v{{ $appVersion }} (sha-{{ $appCommit }})
@endif
</p>
<p>&copy; {{ date('Y') }} {{ config('version.company.name') }}.com, {{ config('version.company.suffix') }}</p>
</div>
<div class="dropdown dropdown-top dropdown-end w-full">
<div
tabindex="0"
role="button"
class="bg-base-200 hover:bg-base-300 rounded-box mx-2 mt-0 flex cursor-pointer items-center gap-2.5 px-3 py-2 transition-all">
<div class="avatar">
<div class="bg-base-200 mask mask-squircle w-8">
<img src="https://ui-avatars.com/api/?name={{ urlencode(trim((auth()->user()->first_name ?? '') . ' ' . (auth()->user()->last_name ?? '')) ?: 'User') }}&color=7F9CF5&background=EBF4FF" alt="Avatar" />
</div>
</div>
<div class="grow -space-y-0.5">
<p class="text-sm font-medium">{{ trim((auth()->user()->first_name ?? '') . ' ' . (auth()->user()->last_name ?? '')) ?: 'User' }}</p>
<p class="text-base-content/60 text-xs">{{ auth()->user()->email ?? '' }}</p>
</div>
<span class="icon-[lucide--chevrons-up-down] text-base-content/60 size-4"></span>
</div>
<ul
role="menu"
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-base-content/20 mb-1 w-48 p-1 shadow-lg">
<li>
<a href="{{ route('seller.settings') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
<span>Account Settings</span>
</a>
</li>
<li>
<a href="{{ route('seller.notifications.index') }}">
<span class="icon-[lucide--bell] size-4"></span>
<span>Notifications</span>
</a>
</li>
<div class="divider my-0"></div>
<li>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="w-full text-left">
<span class="icon-[lucide--log-out] size-4"></span>
<span>Logout</span>
</button>
</form>
</li>
</ul>
<p>&copy; {{ date('Y') }} creationshop.com, LLC</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,76 @@
@php
$currentBusiness = auth()->user()?->primaryBusiness();
$currentView = session('current_view', 'sales');
$views = [
'sales' => [
'name' => 'Sales',
'icon' => 'icon-[lucide--shopping-cart]',
'enabled' => true,
],
'manufacturing' => [
'name' => 'Manufacturing',
'icon' => 'icon-[lucide--factory]',
'enabled' => $currentBusiness?->has_manufacturing ?? false,
],
'compliance' => [
'name' => 'Compliance',
'icon' => 'icon-[lucide--shield-check]',
'enabled' => $currentBusiness?->has_compliance ?? false,
],
];
@endphp
<div class="mb-3 px-2.5 sticky top-0 bg-base-100 z-10 py-2" x-data="{ open: false }">
<div class="relative">
<button
@click="open = !open"
@click.away="open = false"
type="button"
class="bg-base-200 hover:bg-base-300 rounded-box w-full flex items-center justify-between gap-2 px-3 py-2.5 transition-all">
<div class="flex items-center gap-2 min-w-0">
<span class="{{ $views[$currentView]['icon'] }} size-4 flex-shrink-0"></span>
<span class="text-sm font-semibold truncate max-w-[140px]">
{{ $views[$currentView]['name'] }}
</span>
</div>
<span class="icon-[lucide--chevron-down] size-4 flex-shrink-0 transition-transform" :class="{ 'rotate-180': open }"></span>
</button>
<div
x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute z-50 mt-1 w-full bg-base-100 rounded-box shadow-xl border border-base-300"
style="display: none;">
@foreach($views as $viewKey => $viewData)
@if($viewData['enabled'])
<form action="{{ route('seller.business.view.switch', $currentBusiness->slug) }}" method="POST">
@csrf
<input type="hidden" name="view" value="{{ $viewKey }}">
<button
type="submit"
class="w-full px-3 py-2 text-left hover:bg-base-200 transition-colors flex items-center gap-2 {{ $currentView === $viewKey ? 'bg-primary/10 text-primary font-semibold' : '' }}">
<span class="{{ $viewData['icon'] }} size-4"></span>
<span class="truncate">{{ $viewData['name'] }}</span>
@if($currentView === $viewKey)
<span class="icon-[lucide--check] size-4 ml-auto"></span>
@endif
</button>
</form>
@else
<div class="w-full px-3 py-2 flex items-center gap-2 opacity-50 cursor-not-allowed">
<span class="{{ $viewData['icon'] }} size-4"></span>
<span class="truncate">{{ $viewData['name'] }}</span>
<span class="badge badge-sm badge-outline ml-auto">Upgrade</span>
</div>
@endif
@endforeach
</div>
</div>
</div>

View File

@@ -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">&gt;</span>
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="hover:text-gray-700">Products</a>
<span class="mx-2">&gt;</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

View File

@@ -0,0 +1,236 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-2">
<span class="icon-[lucide--scroll-text] size-6"></span>
<p class="text-lg font-medium">Audit Logs</p>
</div>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Settings</a></li>
<li class="opacity-60">Audit Logs</li>
</ul>
</div>
</div>
<!-- Audit Logs Section -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<span class="icon-[lucide--scroll-text] size-6 text-primary"></span>
<h2 class="text-lg font-semibold">System Activity</h2>
</div>
<!-- Filter and Search Controls -->
<div class="flex gap-2">
<button class="btn btn-sm btn-outline gap-2">
<span class="icon-[lucide--filter] size-4"></span>
Add Filter
</button>
<button class="btn btn-sm btn-outline gap-2">
<span class="icon-[lucide--download] size-4"></span>
Export
</button>
</div>
</div>
<!-- Search Bar -->
<div class="mb-4">
<label class="input input-bordered flex items-center gap-2">
<span class="icon-[lucide--search] size-4 text-base-content/40"></span>
<input type="text" class="grow" placeholder="Search audit logs..." id="auditSearch" />
</label>
</div>
<!-- Audit Logs Table -->
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>
<div class="flex items-center gap-1 cursor-pointer hover:text-primary">
Timestamp
<span class="icon-[lucide--arrow-up-down] size-3"></span>
</div>
</th>
<th>User</th>
<th>Action</th>
<th>Resource</th>
<th>IP Address</th>
<th>User Agent</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($audits as $audit)
<tr class="hover">
<td class="text-sm">
<div class="flex flex-col">
<span class="font-medium">{{ $audit->created_at->format('M d, Y') }}</span>
<span class="text-xs text-base-content/60">{{ $audit->created_at->format('h:i:s A') }}</span>
</div>
</td>
<td>
<div class="flex flex-col">
<span class="font-medium text-sm">{{ $audit->user?->name ?? 'System' }}</span>
<span class="text-xs text-base-content/60">{{ $audit->user?->email ?? 'system@automated' }}</span>
</div>
</td>
<td>
@if($audit->event === 'created')
<span class="badge badge-success badge-sm gap-1">
<span class="icon-[lucide--plus-circle] size-3"></span>
Created
</span>
@elseif($audit->event === 'updated')
<span class="badge badge-info badge-sm gap-1">
<span class="icon-[lucide--pencil] size-3"></span>
Updated
</span>
@elseif($audit->event === 'deleted')
<span class="badge badge-error badge-sm gap-1">
<span class="icon-[lucide--trash-2] size-3"></span>
Deleted
</span>
@else
<span class="badge badge-ghost badge-sm">{{ ucfirst($audit->event) }}</span>
@endif
</td>
<td>
<div class="flex flex-col">
<span class="font-medium text-sm">{{ class_basename($audit->auditable_type) }}</span>
<span class="text-xs text-base-content/60">#{{ $audit->auditable_id }}</span>
</div>
</td>
<td class="font-mono text-xs">{{ $audit->ip_address ?? 'N/A' }}</td>
<td class="max-w-xs">
<div class="tooltip tooltip-left" data-tip="{{ $audit->user_agent ?? 'N/A' }}">
<span class="text-xs text-base-content/60 truncate block">
{{ Str::limit($audit->user_agent ?? 'N/A', 30) }}
</span>
</div>
</td>
<td>
<button onclick="viewAuditDetails{{ $audit->id }}.showModal()" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[lucide--eye] size-4"></span>
</button>
</td>
</tr>
<!-- Audit Details Modal -->
<dialog id="viewAuditDetails{{ $audit->id }}" class="modal">
<div class="modal-box max-w-2xl">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg mb-4 flex items-center gap-2">
<span class="icon-[lucide--file-text] size-5"></span>
Audit Details
</h3>
<div class="space-y-4">
<!-- Basic Info -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-xs font-semibold text-base-content/60">Timestamp</label>
<p class="text-sm">{{ $audit->created_at->format('F d, Y \a\t h:i:s A') }}</p>
</div>
<div>
<label class="text-xs font-semibold text-base-content/60">Action</label>
<p class="text-sm">{{ ucfirst($audit->event) }}</p>
</div>
<div>
<label class="text-xs font-semibold text-base-content/60">User</label>
<p class="text-sm">{{ $audit->user?->name ?? 'System' }}</p>
<p class="text-xs text-base-content/60">{{ $audit->user?->email ?? 'system@automated' }}</p>
</div>
<div>
<label class="text-xs font-semibold text-base-content/60">IP Address</label>
<p class="text-sm font-mono">{{ $audit->ip_address ?? 'N/A' }}</p>
</div>
<div class="col-span-2">
<label class="text-xs font-semibold text-base-content/60">Resource</label>
<p class="text-sm">{{ class_basename($audit->auditable_type) }} #{{ $audit->auditable_id }}</p>
</div>
<div class="col-span-2">
<label class="text-xs font-semibold text-base-content/60">User Agent</label>
<p class="text-xs font-mono break-all">{{ $audit->user_agent ?? 'N/A' }}</p>
</div>
</div>
<!-- Changes -->
@if($audit->old_values || $audit->new_values)
<div class="divider"></div>
<div>
<label class="text-xs font-semibold text-base-content/60 mb-2 block">Changes</label>
<div class="space-y-2">
@foreach(array_keys(array_merge($audit->old_values ?? [], $audit->new_values ?? [])) as $field)
<div class="bg-base-200 p-3 rounded-lg">
<div class="font-semibold text-sm mb-1">{{ ucfirst($field) }}</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<span class="text-base-content/60">Old Value:</span>
<div class="bg-error/10 text-error p-2 rounded mt-1 font-mono">{{ $audit->old_values[$field] ?? 'N/A' }}</div>
</div>
<div>
<span class="text-base-content/60">New Value:</span>
<div class="bg-success/10 text-success p-2 rounded mt-1 font-mono">{{ $audit->new_values[$field] ?? 'N/A' }}</div>
</div>
</div>
</div>
@endforeach
</div>
</div>
@endif
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
@empty
<tr>
<td colspan="7" class="text-center py-8 text-base-content/60">
<div class="flex flex-col items-center gap-2">
<span class="icon-[lucide--inbox] size-12 text-base-content/20"></span>
<p>No audit logs found</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Pagination -->
@if($audits->hasPages())
<div class="flex justify-between items-center mt-4">
<div class="text-sm text-base-content/60">
Showing {{ $audits->firstItem() ?? 0 }} to {{ $audits->lastItem() ?? 0 }} of {{ $audits->total() }} entries
</div>
<div>
{{ $audits->links() }}
</div>
</div>
@endif
</div>
</div>
<script>
// Simple client-side search functionality
document.getElementById('auditSearch').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const rows = document.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
</script>
@endsection

View File

@@ -0,0 +1,336 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<p class="text-lg font-medium">Brand Kit</p>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.settings.company-information', $business->slug) }}">Company</a></li>
<li class="opacity-80">Brand Kit</li>
</ul>
</div>
</div>
<!-- Header with Actions -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-base-content flex items-center gap-2">
<span class="icon-[lucide--palette] size-8"></span>
Cannabrands Brand Kit
</h1>
<p class="text-base-content/60 mt-1">Download brand assets for Cannabrands</p>
</div>
<div class="flex gap-2">
<button class="btn btn-outline btn-sm gap-2">
<span class="icon-[lucide--upload] size-4"></span>
Upload
</button>
<button class="btn btn-primary btn-sm gap-2">
<span class="icon-[lucide--download] size-4"></span>
Download All
</button>
</div>
</div>
<div class="space-y-6">
<!-- Logos Section -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title flex items-center gap-2">
<span class="icon-[lucide--image] size-5 text-primary"></span>
Logos
</h2>
<p class="text-sm text-base-content/60 mb-4">
Official Cannabrands logos in various formats
</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Primary Logo SVG -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center justify-center bg-white rounded-lg p-6 mb-3 min-h-[120px]">
<img src="{{ asset('storage/brand-kit/cannabrands/logo-primary.svg') }}"
alt="Cannabrands Primary Logo"
class="max-h-20"
onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%2250%22%3E%3Ctext x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-family=%22Arial%22 font-size=%2214%22 fill=%22%23666%22%3ECannabrandsLogo%3C/text%3E%3C/svg%3E'">
</div>
<h3 class="font-medium text-sm">Primary Logo</h3>
<p class="text-xs text-base-content/60 mb-3">SVG Format</p>
<div class="flex gap-2">
<button class="btn btn-xs btn-outline flex-1 gap-1">
<span class="icon-[lucide--eye] size-3"></span>
View
</button>
<a href="{{ asset('storage/brand-kit/cannabrands/logo-primary.svg') }}"
download
class="btn btn-xs btn-primary flex-1 gap-1">
<span class="icon-[lucide--download] size-3"></span>
Download
</a>
</div>
</div>
</div>
<!-- Primary Logo PNG -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center justify-center bg-white rounded-lg p-6 mb-3 min-h-[120px]">
<img src="{{ asset('storage/brand-kit/cannabrands/logo-primary.png') }}"
alt="Cannabrands Primary Logo PNG"
class="max-h-20"
onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%2250%22%3E%3Ctext x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-family=%22Arial%22 font-size=%2214%22 fill=%22%23666%22%3ECannabrandsLogo%3C/text%3E%3C/svg%3E'">
</div>
<h3 class="font-medium text-sm">Primary Logo</h3>
<p class="text-xs text-base-content/60 mb-3">PNG Format (High-Res)</p>
<div class="flex gap-2">
<button class="btn btn-xs btn-outline flex-1 gap-1">
<span class="icon-[lucide--eye] size-3"></span>
View
</button>
<a href="{{ asset('storage/brand-kit/cannabrands/logo-primary.png') }}"
download
class="btn btn-xs btn-primary flex-1 gap-1">
<span class="icon-[lucide--download] size-3"></span>
Download
</a>
</div>
</div>
</div>
<!-- Secondary Logo SVG -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center justify-center bg-base-300 rounded-lg p-6 mb-3 min-h-[120px]">
<img src="{{ asset('storage/brand-kit/cannabrands/logo-secondary.svg') }}"
alt="Cannabrands Secondary Logo"
class="max-h-20"
onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%2250%22%3E%3Ctext x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22 font-family=%22Arial%22 font-size=%2214%22 fill=%22%23666%22%3ECannabrandsIcon%3C/text%3E%3C/svg%3E'">
</div>
<h3 class="font-medium text-sm">Icon/Mark</h3>
<p class="text-xs text-base-content/60 mb-3">SVG Format</p>
<div class="flex gap-2">
<button class="btn btn-xs btn-outline flex-1 gap-1">
<span class="icon-[lucide--eye] size-3"></span>
View
</button>
<a href="{{ asset('storage/brand-kit/cannabrands/logo-secondary.svg') }}"
download
class="btn btn-xs btn-primary flex-1 gap-1">
<span class="icon-[lucide--download] size-3"></span>
Download
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Colors Section -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title flex items-center gap-2">
<span class="icon-[lucide--palette] size-5 text-primary"></span>
Colors
</h2>
<p class="text-sm text-base-content/60 mb-4">
Official Cannabrands brand colors with hex codes
</p>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
@php
$colors = [
['name' => 'Primary Green', 'hex' => '#10B981', 'description' => 'Main brand color'],
['name' => 'Dark Green', 'hex' => '#047857', 'description' => 'Secondary'],
['name' => 'Light Green', 'hex' => '#D1FAE5', 'description' => 'Backgrounds'],
['name' => 'Charcoal', 'hex' => '#1F2937', 'description' => 'Text primary'],
['name' => 'Gray', 'hex' => '#6B7280', 'description' => 'Text secondary'],
['name' => 'White', 'hex' => '#FFFFFF', 'description' => 'Backgrounds'],
];
@endphp
@foreach($colors as $color)
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-3">
<div class="rounded-lg mb-2 h-20 border border-base-300"
style="background-color: {{ $color['hex'] }};">
</div>
<h3 class="font-medium text-xs">{{ $color['name'] }}</h3>
<p class="text-xs font-mono text-base-content/80">{{ $color['hex'] }}</p>
<p class="text-xs text-base-content/50">{{ $color['description'] }}</p>
</div>
</div>
@endforeach
</div>
<div class="mt-4">
<a href="{{ asset('storage/brand-kit/cannabrands/colors.json') }}"
download
class="btn btn-sm btn-primary gap-2">
<span class="icon-[lucide--download] size-4"></span>
Download Color Palette (JSON)
</a>
</div>
</div>
</div>
<!-- Typography Section -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title flex items-center gap-2">
<span class="icon-[lucide--type] size-5 text-primary"></span>
Typography
</h2>
<p class="text-sm text-base-content/60 mb-4">
Official Cannabrands font files and usage guidelines
</p>
<div class="space-y-4">
<!-- Headings Font -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="font-bold text-lg">Inter Bold</h3>
<p class="text-sm text-base-content/60">Headings & Display Text</p>
</div>
<div class="flex gap-2">
<a href="{{ asset('storage/brand-kit/cannabrands/fonts/Inter-Bold.ttf') }}"
download
class="btn btn-xs btn-outline gap-1">
<span class="icon-[lucide--download] size-3"></span>
TTF
</a>
<a href="{{ asset('storage/brand-kit/cannabrands/fonts/Inter-Bold.woff') }}"
download
class="btn btn-xs btn-primary gap-1">
<span class="icon-[lucide--download] size-3"></span>
WOFF
</a>
</div>
</div>
<div class="text-2xl font-bold">The quick brown fox jumps over the lazy dog</div>
</div>
</div>
<!-- Body Font -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="font-semibold text-lg">Inter Regular</h3>
<p class="text-sm text-base-content/60">Body Text & Paragraphs</p>
</div>
<div class="flex gap-2">
<a href="{{ asset('storage/brand-kit/cannabrands/fonts/Inter-Regular.ttf') }}"
download
class="btn btn-xs btn-outline gap-1">
<span class="icon-[lucide--download] size-3"></span>
TTF
</a>
<a href="{{ asset('storage/brand-kit/cannabrands/fonts/Inter-Regular.woff') }}"
download
class="btn btn-xs btn-primary gap-1">
<span class="icon-[lucide--download] size-3"></span>
WOFF
</a>
</div>
</div>
<div class="text-base">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.</div>
</div>
</div>
</div>
</div>
</div>
<!-- Templates Section -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="card-title flex items-center gap-2">
<span class="icon-[lucide--layout-template] size-5 text-primary"></span>
Templates
</h2>
<p class="text-sm text-base-content/60 mb-4">
Ready-to-use templates for marketing materials
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Email Template -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center gap-3 mb-3">
<div class="rounded-lg bg-primary/10 p-3">
<span class="icon-[lucide--mail] size-6 text-primary"></span>
</div>
<div class="flex-1">
<h3 class="font-medium">Email Template</h3>
<p class="text-xs text-base-content/60">HTML email template</p>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-xs btn-outline flex-1 gap-1">
<span class="icon-[lucide--eye] size-3"></span>
Preview
</button>
<a href="{{ asset('storage/brand-kit/cannabrands/templates/email-template.html') }}"
download
class="btn btn-xs btn-primary flex-1 gap-1">
<span class="icon-[lucide--download] size-3"></span>
Download
</a>
</div>
</div>
</div>
<!-- Social Media Template -->
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center gap-3 mb-3">
<div class="rounded-lg bg-primary/10 p-3">
<span class="icon-[lucide--share-2] size-6 text-primary"></span>
</div>
<div class="flex-1">
<h3 class="font-medium">Social Media</h3>
<p class="text-xs text-base-content/60">Instagram & Facebook posts</p>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-xs btn-outline flex-1 gap-1">
<span class="icon-[lucide--eye] size-3"></span>
Preview
</button>
<a href="{{ asset('storage/brand-kit/cannabrands/templates/social-media.zip') }}"
download
class="btn btn-xs btn-primary flex-1 gap-1">
<span class="icon-[lucide--download] size-3"></span>
Download
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Brand Guidelines Note -->
<div class="alert alert-info">
<span class="icon-[lucide--info] size-5"></span>
<div>
<h3 class="font-bold">Brand Guidelines</h3>
<div class="text-sm">
Please follow Cannabrands brand guidelines when using these assets.
For questions or custom requests, contact the marketing team.
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
// Preview modal functionality can be added here
console.log('Brand Kit page loaded');
</script>
@endpush

View File

@@ -0,0 +1,136 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<p class="text-lg font-medium">Create {{ ucfirst($type) }} Category</p>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.settings.categories.index', $business->slug) }}">Categories</a></li>
<li class="opacity-80">Create</li>
</ul>
</div>
</div>
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-base-content">Create {{ ucfirst($type) }} Category</h1>
<p class="text-base-content/60 mt-1">Add a new {{ $type }} category for organizing your {{ $type }}s</p>
</div>
<a href="{{ route('seller.business.settings.categories.index', $business->slug) }}" class="btn btn-ghost">
<span class="icon-[lucide--arrow-left] size-4"></span>
Back to Categories
</a>
</div>
<!-- Create Form -->
<div class="card bg-base-100 shadow max-w-2xl">
<form method="POST" action="{{ route('seller.business.settings.categories.store', [$business->slug, $type]) }}" enctype="multipart/form-data" class="card-body">
@csrf
<!-- Category Image -->
<div class="form-control">
<label class="label">
<span class="label-text">Category Image</span>
</label>
<input type="file" name="image" accept="image/*" class="file-input file-input-bordered w-full" />
@error('image')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<!-- Name -->
<div class="form-control">
<label class="label">
<span class="label-text">Name <span class="text-error">*</span></span>
</label>
<input type="text" name="name" value="{{ old('name') }}" class="input input-bordered w-full" required autofocus />
@error('name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<!-- Parent Category -->
<div class="form-control">
<label class="label">
<span class="label-text">Parent</span>
</label>
<select name="parent_id" class="select select-bordered w-full">
<option value="">Top-level Category</option>
@foreach($categories as $cat)
<option value="{{ $cat->id }}" {{ old('parent_id', request('parent')) == $cat->id ? 'selected' : '' }}>
{{ $cat->name }}
</option>
@if($cat->children && $cat->children->count() > 0)
@foreach($cat->children as $child)
<option value="{{ $child->id }}" {{ old('parent_id', request('parent')) == $child->id ? 'selected' : '' }}>
&nbsp;&nbsp; {{ $child->name }}
</option>
@endforeach
@endif
@endforeach
</select>
<label class="label">
<span class="label-text-alt">Leave blank to create a top-level category</span>
</label>
@error('parent_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<!-- Description -->
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea name="description" rows="4" class="textarea textarea-bordered w-full">{{ old('description') }}</textarea>
@error('description')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<!-- Sort Order -->
<div class="form-control">
<label class="label">
<span class="label-text">Sort Order</span>
</label>
<input type="number" name="sort_order" value="{{ old('sort_order', 0) }}" min="0" class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt">Lower numbers appear first</span>
</label>
@error('sort_order')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<!-- Active Status -->
<div class="form-control">
<label class="label">
<span class="label-text">Active Status</span>
</label>
<div>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="is_active" value="1" class="checkbox" {{ old('is_active', true) ? 'checked' : '' }} />
<span class="label-text">Active</span>
</label>
<label class="label pt-0">
<span class="label-text-alt">Inactive categories are hidden from selection</span>
</label>
</div>
</div>
<!-- Form Actions -->
<div class="card-actions justify-end mt-6">
<a href="{{ route('seller.business.settings.categories.index', $business->slug) }}" class="btn btn-ghost">
Cancel
</a>
<button type="submit" class="btn">
<span class="icon-[lucide--save] size-4"></span>
Save Category
</button>
</div>
</form>
</div>
@endsection

View File

@@ -0,0 +1,151 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<p class="text-lg font-medium">Edit {{ ucfirst($type) }} Category</p>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.settings.categories.index', $business->slug) }}">Categories</a></li>
<li class="opacity-80">Edit</li>
</ul>
</div>
</div>
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-base-content">Edit {{ ucfirst($type) }} Category</h1>
<p class="text-base-content/60 mt-1">Update category information</p>
</div>
<a href="{{ route('seller.business.settings.categories.index', $business->slug) }}" class="btn btn-ghost">
<span class="icon-[lucide--arrow-left] size-4"></span>
Back to Categories
</a>
</div>
<!-- Edit Form -->
<div class="card bg-base-100 shadow max-w-2xl">
<form method="POST" action="{{ route('seller.business.settings.categories.update', [$business->slug, $type, $category->id]) }}" enctype="multipart/form-data" class="card-body">
@csrf
@method('PUT')
<!-- Current Image -->
@if($category->image_path)
<div class="form-control">
<label class="label">
<span class="label-text">Current Image</span>
</label>
<div class="avatar">
<div class="w-24 rounded">
<img src="{{ asset('storage/' . $category->image_path) }}" alt="{{ $category->name }}" />
</div>
</div>
</div>
@endif
<!-- Category Image -->
<div class="form-control">
<label class="label">
<span class="label-text">{{ $category->image_path ? 'Replace' : 'Upload' }} Category Image</span>
</label>
<input type="file" name="image" accept="image/*" class="file-input file-input-bordered w-full" />
@error('image')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<!-- Name -->
<div class="form-control">
<label class="label">
<span class="label-text">Name <span class="text-error">*</span></span>
</label>
<input type="text" name="name" value="{{ old('name', $category->name) }}" class="input input-bordered w-full" required autofocus />
@error('name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<!-- Parent Category -->
<div class="form-control">
<label class="label">
<span class="label-text">Parent</span>
</label>
<select name="parent_id" class="select select-bordered w-full">
<option value="">Top-level Category</option>
@foreach($categories as $cat)
<option value="{{ $cat->id }}" {{ old('parent_id', $category->parent_id) == $cat->id ? 'selected' : '' }}>
{{ $cat->name }}
</option>
@if($cat->children && $cat->children->count() > 0)
@foreach($cat->children as $child)
<option value="{{ $child->id }}" {{ old('parent_id', $category->parent_id) == $child->id ? 'selected' : '' }}>
&nbsp;&nbsp; {{ $child->name }}
</option>
@endforeach
@endif
@endforeach
</select>
<label class="label">
<span class="label-text-alt">Leave blank for a top-level category</span>
</label>
@error('parent_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<!-- Description -->
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea name="description" rows="4" class="textarea textarea-bordered w-full">{{ old('description', $category->description) }}</textarea>
@error('description')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<!-- Sort Order -->
<div class="form-control">
<label class="label">
<span class="label-text">Sort Order</span>
</label>
<input type="number" name="sort_order" value="{{ old('sort_order', $category->sort_order ?? 0) }}" min="0" class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt">Lower numbers appear first</span>
</label>
@error('sort_order')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<!-- Active Status -->
<div class="form-control">
<label class="label">
<span class="label-text">Active Status</span>
</label>
<div>
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" name="is_active" value="1" class="checkbox" {{ old('is_active', $category->is_active) ? 'checked' : '' }} />
<span class="label-text">Active</span>
</label>
<label class="label pt-0">
<span class="label-text-alt">Inactive categories are hidden from selection</span>
</label>
</div>
</div>
<!-- Form Actions -->
<div class="card-actions justify-end mt-6">
<a href="{{ route('seller.business.settings.categories.index', $business->slug) }}" class="btn btn-ghost">
Cancel
</a>
<button type="submit" class="btn">
<span class="icon-[lucide--save] size-4"></span>
Update Category
</button>
</div>
</form>
</div>
@endsection

View File

@@ -0,0 +1,142 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Categories</p>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Settings</a></li>
<li class="opacity-80">Categories</li>
</ul>
</div>
</div>
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-base-content flex items-center gap-2">
<span class="icon-[lucide--folder-tree] size-8"></span>
Categories
</h1>
<p class="text-base-content/60 mt-1">Product and component categories for {{ $business->name }}</p>
</div>
</div>
<!-- Flash Messages -->
@if(session('success'))
<div class="alert mb-6">
<span class="icon-[lucide--check-circle] size-5"></span>
<span>{{ session('success') }}</span>
</div>
@endif
@if(session('error'))
<div class="alert mb-6">
<span class="icon-[lucide--alert-circle] size-5"></span>
<span>{{ session('error') }}</span>
</div>
@endif
<!-- Categories Tables -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Product Categories -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title text-xl">
<span class="icon-[lucide--package] size-6"></span>
Product Categories
</h2>
<a href="{{ route('seller.business.settings.categories.create', [$business->slug, 'product']) }}"
class="btn btn-sm">
<span class="icon-[lucide--plus] size-4"></span>
Add Category
</a>
</div>
@if ($productCategories->isEmpty())
<div class="text-center py-8">
<span class="icon-[lucide--folder-x] size-12 text-base-content/20 mx-auto block mb-2"></span>
<p class="text-base-content/60 mb-4">No product categories found</p>
<a href="{{ route('seller.business.settings.categories.create', [$business->slug, 'product']) }}"
class="btn btn-sm">
<span class="icon-[lucide--plus] size-4"></span>
Create First Category
</a>
</div>
@else
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th class="text-right">Products</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach ($productCategories as $category)
<x-category-tree-item :category="$category" type="product" :business="$business" />
@endforeach
</tbody>
</table>
</div>
<div class="mt-4 text-sm text-base-content/60">
Total: {{ $productCategories->count() }} top-level categories
</div>
@endif
</div>
</div>
<!-- Component Categories -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title text-xl">
<span class="icon-[lucide--box] size-6"></span>
Component Categories
</h2>
<a href="{{ route('seller.business.settings.categories.create', [$business->slug, 'component']) }}"
class="btn btn-sm">
<span class="icon-[lucide--plus] size-4"></span>
Add Category
</a>
</div>
@if ($componentCategories->isEmpty())
<div class="text-center py-8">
<span class="icon-[lucide--folder-x] size-12 text-base-content/20 mx-auto block mb-2"></span>
<p class="text-base-content/60 mb-4">No component categories found</p>
<a href="{{ route('seller.business.settings.categories.create', [$business->slug, 'component']) }}"
class="btn btn-sm">
<span class="icon-[lucide--plus] size-4"></span>
Create First Category
</a>
</div>
@else
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th class="text-right">Components</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach ($componentCategories as $category)
<x-category-tree-item :category="$category" type="component" :business="$business" />
@endforeach
</tbody>
</table>
</div>
<div class="mt-4 text-sm text-base-content/60">
Total: {{ $componentCategories->count() }} top-level categories
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -2,27 +2,387 @@
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Company Information</p>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Company Information</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Company</a></li>
<li class="opacity-80">Company Information</li>
<li><a href="{{ route('seller.business.settings.company-information', $business->slug) }}">Settings</a></li>
<li class="opacity-60">Company Information</li>
</ul>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Company Information Settings</h2>
<p class="text-base-content/60">Manage your company details, DBA, address, and other information.</p>
<form action="{{ route('seller.business.settings.company-information.update', $business->slug) }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="mt-4">
<p class="text-sm text-base-content/60">This page is under construction.</p>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content (Left Side - 2 columns) -->
<div class="lg:col-span-2 space-y-6">
<!-- Company Overview -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Company Overview</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Company Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Company Name <span class="text-error">*</span></span>
</label>
<input type="text" name="name" value="{{ old('name', $business->name) }}"
class="input input-bordered @error('name') input-error @enderror"
placeholder="Your company name" required>
@error('name')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- DBA Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">DBA Name</span>
<span class="label-text-alt tooltip tooltip-left" data-tip="'Doing Business As' name if different from legal name">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input type="text" name="dba_name" value="{{ old('dba_name', $business->dba_name) }}"
class="input input-bordered @error('dba_name') input-error @enderror"
placeholder="Trade name or DBA">
@error('dba_name')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Description (Full Width - Spans 2 columns) -->
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Company Description</span>
<span class="label-text-alt flex items-center gap-2">
<span class="tooltip tooltip-left" data-tip="This appears in the About Company modal">
<span class="icon-[lucide--info] size-4"></span>
</span>
<span class="text-sm" id="description-count">0/500</span>
</span>
</label>
<textarea name="description"
rows="4"
class="textarea textarea-bordered w-full resize-none @error('description') textarea-error @enderror"
placeholder="Describe your company, mission, and what makes you unique"
maxlength="500"
oninput="updateCharCount('description', 'description-count', 500)">{{ old('description', $business->description) }}</textarea>
@error('description')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
</div>
<!-- Company Branding -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Company Branding</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Company Logo -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Company Logo</span>
<span class="label-text-alt tooltip tooltip-left" data-tip="Displayed in About Company modal">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
@if($business->logo_path && \Storage::disk('public')->exists($business->logo_path))
<div class="mb-2">
<img src="{{ asset('storage/' . $business->logo_path) }}" alt="Company logo" class="w-32 h-32 rounded-lg border border-base-300 object-contain bg-base-50 p-2">
</div>
@endif
<input type="file" name="logo" accept="image/*"
class="file-input file-input-bordered @error('logo') file-input-error @enderror"
onchange="previewImage(this, 'logo-preview')">
<label class="label">
<span class="label-text-alt">Max 2MB. Recommended: Square (512x512px)</span>
</label>
@error('logo')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
<div id="logo-preview" class="mt-2 hidden">
<p class="text-sm text-base-content/60 mb-1">Preview:</p>
<img src="" alt="Logo preview" class="w-32 h-32 rounded-lg border border-base-300 object-contain bg-base-50 p-2">
</div>
@if($business->logo_path && \Storage::disk('public')->exists($business->logo_path))
<label class="label cursor-pointer justify-start gap-2 mt-2">
<input type="checkbox" name="remove_logo" value="1" class="checkbox checkbox-sm">
<span class="label-text text-sm">Remove current logo</span>
</label>
@endif
</div>
<!-- Company Banner -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Company Banner</span>
<span class="label-text-alt tooltip tooltip-left" data-tip="Displayed as hero banner on brand preview pages">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
@if($business->banner_path && \Storage::disk('public')->exists($business->banner_path))
<div class="mb-2">
<img src="{{ asset('storage/' . $business->banner_path) }}" alt="Company banner" class="w-full h-24 rounded-lg border border-base-300 object-cover">
</div>
@endif
<input type="file" name="banner" accept="image/*"
class="file-input file-input-bordered @error('banner') file-input-error @enderror"
onchange="previewImage(this, 'banner-preview')">
<label class="label">
<span class="label-text-alt">Max 4MB. Recommended: 1920x640px (3:1 ratio)</span>
</label>
@error('banner')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
<div id="banner-preview" class="mt-2 hidden">
<p class="text-sm text-base-content/60 mb-1">Preview:</p>
<img src="" alt="Banner preview" class="w-full h-24 rounded-lg border border-base-300 object-cover">
</div>
@if($business->banner_path && \Storage::disk('public')->exists($business->banner_path))
<label class="label cursor-pointer justify-start gap-2 mt-2">
<input type="checkbox" name="remove_banner" value="1" class="checkbox checkbox-sm">
<span class="label-text text-sm">Remove current banner</span>
</label>
@endif
</div>
</div>
</div>
</div>
<!-- Contact Information -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Contact Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Business Phone -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Business Phone</span>
</label>
<input type="tel" name="business_phone" value="{{ old('business_phone', $business->business_phone) }}"
class="input input-bordered @error('business_phone') input-error @enderror"
placeholder="(555) 123-4567">
@error('business_phone')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Business Email -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Business Email</span>
</label>
<input type="email" name="business_email" value="{{ old('business_email', $business->business_email) }}"
class="input input-bordered @error('business_email') input-error @enderror"
placeholder="info@company.com">
@error('business_email')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
<!-- Physical Address -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Physical Address</h2>
<div class="grid grid-cols-12 gap-4">
<!-- Street Address -->
<div class="form-control col-span-12 md:col-span-8">
<label class="label">
<span class="label-text font-medium">Street Address</span>
</label>
<input type="text" name="physical_address" value="{{ old('physical_address', $business->physical_address) }}"
class="input input-bordered w-full @error('physical_address') input-error @enderror"
placeholder="123 Main Street">
@error('physical_address')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Suite/Unit Number -->
<div class="form-control col-span-12 md:col-span-4">
<label class="label">
<span class="label-text font-medium">Suite/Unit</span>
</label>
<input type="text" name="physical_suite" value="{{ old('physical_suite', $business->physical_suite) }}"
class="input input-bordered w-full @error('physical_suite') input-error @enderror"
placeholder="Suite 100">
@error('physical_suite')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- City -->
<div class="form-control col-span-12 md:col-span-6">
<label class="label">
<span class="label-text font-medium">City</span>
</label>
<input type="text" name="physical_city" value="{{ old('physical_city', $business->physical_city) }}"
class="input input-bordered w-full @error('physical_city') input-error @enderror"
placeholder="Phoenix">
@error('physical_city')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- State -->
<div class="form-control col-span-12 md:col-span-3">
<label class="label">
<span class="label-text font-medium">State</span>
</label>
<input type="text" name="physical_state" value="{{ old('physical_state', $business->physical_state) }}"
class="input input-bordered w-full @error('physical_state') input-error @enderror"
placeholder="AZ"
maxlength="2">
@error('physical_state')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- ZIP Code -->
<div class="form-control col-span-12 md:col-span-3">
<label class="label">
<span class="label-text font-medium">ZIP Code</span>
</label>
<input type="text" name="physical_zipcode" value="{{ old('physical_zipcode', $business->physical_zipcode) }}"
class="input input-bordered w-full @error('physical_zipcode') input-error @enderror"
placeholder="85001">
@error('physical_zipcode')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
</div>
<!-- Right Sidebar (1 column) -->
<div class="lg:col-span-1 space-y-6">
<!-- License Information -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">License Information</h2>
<div class="space-y-4">
<!-- License Number -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">License Number</span>
</label>
<input type="text" name="license_number" value="{{ old('license_number', $business->license_number) }}"
class="input input-bordered @error('license_number') input-error @enderror"
placeholder="AZ-MED-00001234">
@error('license_number')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- License Type -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">License Type</span>
</label>
<select name="license_type" class="select select-bordered @error('license_type') select-error @enderror">
<option value="">Select license type</option>
<option value="medical" {{ old('license_type', $business->license_type) == 'medical' ? 'selected' : '' }}>Medical</option>
<option value="adult-use" {{ old('license_type', $business->license_type) == 'adult-use' ? 'selected' : '' }}>Adult Use</option>
<option value="both" {{ old('license_type', $business->license_type) == 'both' ? 'selected' : '' }}>Both</option>
</select>
@error('license_type')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="btn btn-ghost gap-2">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Changes
</button>
</div>
</form>
@endsection
@push('scripts')
<script>
// Character counter
function updateCharCount(textareaName, counterId, maxLength) {
const textarea = document.querySelector(`[name="${textareaName}"]`);
const counter = document.getElementById(counterId);
if (textarea && counter) {
counter.textContent = `${textarea.value.length}/${maxLength}`;
}
}
// Image preview
function previewImage(input, previewId) {
const preview = document.getElementById(previewId);
const img = preview.querySelector('img');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
img.src = e.target.result;
preview.classList.remove('hidden');
}
reader.readAsDataURL(input.files[0]);
}
}
// Initialize character counters on page load
document.addEventListener('DOMContentLoaded', function() {
updateCharCount('description', 'description-count', 500);
});
</script>
@endpush

View File

@@ -0,0 +1,302 @@
@extends('layouts.app-with-sidebar')
@section('content')
<div class="bg-gradient-to-br from-slate-50 to-slate-100 py-8 px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto">
<!-- Header Section -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<h1 class="text-3xl font-bold text-gray-900">Integrations</h1>
</div>
<p class="text-gray-600 text-lg">Connect Cannabrands with your favorite apps to streamline your workflow</p>
</div>
<!-- Search and Filter Section -->
<div class="mb-8 flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<div class="relative">
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input
type="text"
placeholder="Search integrations..."
class="input input-bordered w-full pl-10 bg-white shadow-sm"
>
</div>
</div>
<select class="select select-bordered bg-white shadow-sm w-full sm:w-48">
<option disabled selected>Category</option>
<option>All Categories</option>
<option>Accounting</option>
<option>Time Tracking</option>
<option>Expense Management</option>
<option>Financial Services</option>
<option>Analytics</option>
</select>
</div>
<!-- Integrations Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Integration Card 1: Dext Prepare -->
<div class="card bg-white shadow-md hover:shadow-xl transition-shadow duration-300 border border-gray-200">
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1">
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="flex-1">
<h3 class="font-bold text-gray-900">Dext Prepare</h3>
<p class="text-sm text-gray-600 leading-relaxed">Automated receipt and invoice capture for seamless expense tracking</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 mb-4">
<div class="flex items-center">
<span class="text-sm font-semibold text-gray-900">4.8</span>
<div class="flex gap-0.5 ml-1">
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
</div>
</div>
<span class="text-xs text-gray-500">(247 reviews)</span>
</div>
<button class="btn btn-primary btn-block">Connect</button>
</div>
</div>
<!-- Integration Card 2: Bill.com -->
<div class="card bg-white shadow-md hover:shadow-xl transition-shadow duration-300 border border-gray-200">
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1">
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-green-400 to-green-600 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="flex-1">
<h3 class="font-bold text-gray-900">Bill.com</h3>
<p class="text-sm text-gray-600 leading-relaxed">Streamline bill payments and receivables management</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 mb-4">
<div class="flex items-center">
<span class="text-sm font-semibold text-gray-900">4.7</span>
<div class="flex gap-0.5 ml-1">
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
</div>
</div>
<span class="text-xs text-gray-500">(193 reviews)</span>
</div>
<button class="btn btn-primary btn-block">Connect</button>
</div>
</div>
<!-- Integration Card 3: QuickBooks Time PREMIUM -->
<div class="card bg-white shadow-md hover:shadow-xl transition-shadow duration-300 border border-gray-200">
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1">
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-white" 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>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-bold text-gray-900">QuickBooks Time</h3>
<span class="badge badge-sm badge-accent font-bold text-xs">PREMIUM</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed">Track employee hours and streamline payroll</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 mb-4">
<div class="flex items-center">
<span class="text-sm font-semibold text-gray-900">4.6</span>
<div class="flex gap-0.5 ml-1">
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-gray-300 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
</div>
</div>
<span class="text-xs text-gray-500">(156 reviews)</span>
</div>
<button class="btn btn-primary btn-block">Connect</button>
</div>
</div>
<!-- Integration Card 4: Fundbox -->
<div class="card bg-white shadow-md hover:shadow-xl transition-shadow duration-300 border border-gray-200">
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1">
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-purple-400 to-purple-600 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="flex-1">
<h3 class="font-bold text-gray-900">Fundbox</h3>
<p class="text-sm text-gray-600 leading-relaxed">Fast business lines of credit when you need it</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 mb-4">
<div class="flex items-center">
<span class="text-sm font-semibold text-gray-900">4.5</span>
<div class="flex gap-0.5 ml-1">
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
</div>
</div>
<span class="text-xs text-gray-500">(128 reviews)</span>
</div>
<button class="btn btn-primary btn-block">Connect</button>
</div>
</div>
<!-- Integration Card 5: Expensify PREMIUM -->
<div class="card bg-white shadow-md hover:shadow-xl transition-shadow duration-300 border border-gray-200">
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1">
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-indigo-400 to-indigo-600 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"></path>
</svg>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-bold text-gray-900">Expensify</h3>
<span class="badge badge-sm badge-accent font-bold text-xs">PREMIUM</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed">Automate expense reporting and receipts management</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 mb-4">
<div class="flex items-center">
<span class="text-sm font-semibold text-gray-900">4.9</span>
<div class="flex gap-0.5 ml-1">
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
</div>
</div>
<span class="text-xs text-gray-500">(312 reviews)</span>
</div>
<button class="btn btn-primary btn-block">Connect</button>
</div>
</div>
<!-- Integration Card 6: Fathom -->
<div class="card bg-white shadow-md hover:shadow-xl transition-shadow duration-300 border border-gray-200">
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1">
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-red-400 to-red-600 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
</div>
<div class="flex-1">
<h3 class="font-bold text-gray-900">Fathom Analytics</h3>
<p class="text-sm text-gray-600 leading-relaxed">Get detailed insights into your business performance</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 mb-4">
<div class="flex items-center">
<span class="text-sm font-semibold text-gray-900">4.4</span>
<div class="flex gap-0.5 ml-1">
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
<svg class="w-4 h-4 text-gray-300 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
</div>
</div>
<span class="text-xs text-gray-500">(89 reviews)</span>
</div>
<button class="btn btn-primary btn-block">Connect</button>
</div>
</div>
</div>
<!-- Load More Button -->
<div class="flex justify-center">
<button class="btn btn-outline btn-lg 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>
Load More Integrations
</button>
</div>
</div>
</div>
<style>
/* Smooth hover effects for cards */
.card {
transition: all 0.3s ease;
}
.card:hover {
transform: translateY(-4px);
}
/* Star rating animation */
svg.fill-current {
transition: all 0.2s ease;
}
/* Badge styling */
.badge-accent {
background-color: #f59e0b;
color: white;
}
/* Smooth transitions on buttons */
.btn {
transition: all 0.3s ease;
}
/* Icon backgrounds with gradient */
.bg-gradient-to-br {
background-image: linear-gradient(135deg, var(--tw-gradient-stops));
}
</style>
@endsection

View File

@@ -2,27 +2,264 @@
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Notifications</p>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Email Settings</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Company</a></li>
<li class="opacity-80">Notifications</li>
<li><a href="{{ route('seller.business.settings.notifications', $business->slug) }}">Settings</a></li>
<li class="opacity-60">Notifications</li>
</ul>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Notification Preferences</h2>
<p class="text-base-content/60">Configure email and system notification preferences.</p>
<p class="text-sm text-base-content/60 mb-6">Customize email notification settings.</p>
<div class="mt-4">
<p class="text-sm text-base-content/60">This page is under construction.</p>
<form action="{{ route('seller.business.settings.notifications.update', $business->slug) }}" method="POST">
@csrf
@method('PUT')
<!-- New Order Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">New Order Email Notifications</h2>
<div class="space-y-4">
<!-- Email List -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Addresses</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Comma-separated email addresses to notify when a new order is placed">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input
type="text"
name="new_order_email_notifications"
value="{{ old('new_order_email_notifications', $business->new_order_email_notifications) }}"
class="input input-bordered @error('new_order_email_notifications') input-error @enderror"
placeholder="email1@example.com, email2@example.com"
/>
@error('new_order_email_notifications')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Conditional Options -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="new_order_only_when_no_sales_rep"
value="1"
class="checkbox checkbox-primary"
{{ old('new_order_only_when_no_sales_rep', $business->new_order_only_when_no_sales_rep) ? 'checked' : '' }}
/>
<span class="label-text">Only send New Order Email notifications when no sales reps are assigned to the buyer's account.</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="new_order_do_not_send_to_admins"
value="1"
class="checkbox checkbox-primary"
{{ old('new_order_do_not_send_to_admins', $business->new_order_do_not_send_to_admins) ? 'checked' : '' }}
/>
<span class="label-text">Do not send notifications to company admins.</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Order Accepted Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Accepted Email Notifications</h2>
<div class="space-y-4">
<!-- Email List -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Addresses</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Notify fulfillment and warehouse teams when an order is accepted">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input
type="text"
name="order_accepted_email_notifications"
value="{{ old('order_accepted_email_notifications', $business->order_accepted_email_notifications) }}"
class="input input-bordered @error('order_accepted_email_notifications') input-error @enderror"
placeholder="fulfillment@example.com, warehouse@example.com"
/>
@error('order_accepted_email_notifications')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Enable Shipped Emails For Sales Reps -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="enable_shipped_emails_for_sales_reps"
value="1"
class="checkbox checkbox-primary"
{{ old('enable_shipped_emails_for_sales_reps', $business->enable_shipped_emails_for_sales_reps) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Enable Shipped Emails For Sales Reps</span>
<p class="text-xs text-base-content/60 mt-1">When checked, sales reps assigned to a customer will receive an email when an order for one of their customers is marked Shipped</p>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Platform Inquiry Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Platform Inquiry Email Notifications</h2>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Addresses</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Sales reps always get notified. If blank and no sales reps exist, admins are notified.">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input
type="text"
name="platform_inquiry_email_notifications"
value="{{ old('platform_inquiry_email_notifications', $business->platform_inquiry_email_notifications) }}"
class="input input-bordered @error('platform_inquiry_email_notifications') input-error @enderror"
placeholder="sales@example.com"
/>
@error('platform_inquiry_email_notifications')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Manual Order Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Manual Order Email Notifications</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="enable_manual_order_email_notifications"
value="1"
class="checkbox checkbox-primary"
{{ old('enable_manual_order_email_notifications', $business->enable_manual_order_email_notifications) ? 'checked' : '' }}
/>
<span class="label-text font-medium">Enable Manual Order Email Notifications</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="When enabled, all the same emails sent for buyer-created orders will also be sent for orders you create. When disabled, notifications are only sent for buyer-created orders.">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="manual_order_emails_internal_only"
value="1"
class="checkbox checkbox-primary"
{{ old('manual_order_emails_internal_only', $business->manual_order_emails_internal_only) ? 'checked' : '' }}
/>
<span class="label-text font-medium">Manual Order Emails Internal Only</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Email notifications for manual orders will be sent to internal recipients only and not to buyers">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
</div>
</div>
</div>
</div>
<!-- Low Inventory Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Low Inventory Email Notifications</h2>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Addresses</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Notify these addresses when inventory levels are low">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input
type="text"
name="low_inventory_email_notifications"
value="{{ old('low_inventory_email_notifications', $business->low_inventory_email_notifications) }}"
class="input input-bordered @error('low_inventory_email_notifications') input-error @enderror"
placeholder="inventory@example.com"
/>
@error('low_inventory_email_notifications')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Certified Seller Status Email Notifications -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Certified Seller Status Email Notifications</h2>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Addresses</span>
<span class="label-text-alt tooltip tooltip-right" data-tip="Notify these addresses when certified seller status changes">
<span class="icon-[lucide--info] size-4"></span>
</span>
</label>
<input
type="text"
name="certified_seller_status_email_notifications"
value="{{ old('certified_seller_status_email_notifications', $business->certified_seller_status_email_notifications) }}"
class="input input-bordered @error('certified_seller_status_email_notifications') input-error @enderror"
placeholder="admin@example.com"
/>
@error('certified_seller_status_email_notifications')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="btn btn-ghost gap-2">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Settings
</button>
</div>
</form>
@endsection

View File

@@ -2,27 +2,760 @@
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<div class="flex items-center justify-between mb-6">
<p class="text-lg font-medium">Plans and Billing</p>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Company</a></li>
<li class="opacity-80">Plans and Billing</li>
<li><a>Settings</a></li>
<li class="opacity-60">Plans and Billing</li>
</ul>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Subscription and Billing</h2>
<p class="text-base-content/60">Manage your subscription plan and billing information.</p>
@php
// Mock data - replace with actual data from controller
$currentPlan = [
'name' => 'Marketplace Business',
'price' => 395.00,
'interval' => 'month',
'features' => [
'Unlimited products',
'Advanced analytics',
'Priority support',
'Custom branding',
],
];
<div class="mt-4">
<p class="text-sm text-base-content/60">This page is under construction.</p>
// Scheduled downgrade (mock - will come from subscription model)
$scheduledDowngrade = [
'plan_name' => 'Marketplace Standard',
'plan_price' => 99.00,
'change_date' => '2025-12-17', // Next billing cycle
];
// Set to null to hide banner: $scheduledDowngrade = null;
$paymentMethods = [
[
'id' => 1,
'type' => 'card',
'brand' => 'Visa',
'last4' => '4242',
'exp_month' => 12,
'exp_year' => 2025,
'is_default' => true,
'billing_address' => '123 Main St',
'billing_city' => 'Phoenix',
'billing_state' => 'AZ',
'billing_zip' => '85001',
],
[
'id' => 2,
'type' => 'card',
'brand' => 'Mastercard',
'last4' => '8888',
'exp_month' => 6,
'exp_year' => 2026,
'is_default' => false,
'billing_address' => '456 Oak Ave',
'billing_city' => 'Scottsdale',
'billing_state' => 'AZ',
'billing_zip' => '85251',
],
];
$billingContacts = [
['email' => 'llaz@cannabrands.biz', 'is_primary' => true],
['email' => 'accounting@cannabrands.biz', 'is_primary' => false],
];
$invoices = collect([
['id' => 'INV-242798', 'date' => '2025-10-17', 'amount' => 99.00, 'status' => 'paid'],
['id' => 'INV-240793', 'date' => '2025-09-17', 'amount' => 99.00, 'status' => 'paid'],
['id' => 'INV-238747', 'date' => '2025-08-17', 'amount' => 99.00, 'status' => 'paid'],
['id' => 'INV-236879', 'date' => '2025-08-01', 'amount' => 99.00, 'status' => 'pending'],
['id' => 'INV-235321', 'date' => '2025-07-17', 'amount' => 99.00, 'status' => 'past_due'],
['id' => 'INV-233456', 'date' => '2025-06-17', 'amount' => 99.00, 'status' => 'paid'],
]);
@endphp
<!-- Scheduled Downgrade Banner -->
@if(isset($scheduledDowngrade) && $scheduledDowngrade)
<div role="alert" class="alert alert-warning mb-6">
<span class="icon-[lucide--clock] size-6"></span>
<div class="flex-1">
<h3 class="font-semibold">Plan Change Scheduled</h3>
<div class="text-sm opacity-80">
Your plan will be downgraded to <strong>{{ $scheduledDowngrade['plan_name'] }}</strong>
(${{ number_format($scheduledDowngrade['plan_price'], 2) }}/month) on
<strong>{{ \Carbon\Carbon::parse($scheduledDowngrade['change_date'])->format('F j, Y') }}</strong>.
You'll continue to have access to your current {{ $currentPlan['name'] }} features until then.
</div>
</div>
<button class="btn btn-sm btn-ghost" onclick="document.getElementById('cancel_downgrade_modal').showModal()">
<span class="icon-[lucide--x] size-4"></span>
Cancel Downgrade
</button>
</div>
@endif
<!-- Current Plan and Billing Info Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- Current Plan Card -->
<div class="card bg-base-100 border border-base-300 h-full">
<div class="card-body flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<span class="icon-[lucide--package] size-5 text-primary"></span>
</div>
<h2 class="text-lg font-semibold">Current Plan</h2>
</div>
<button class="btn btn-ghost btn-sm btn-square">
<span class="icon-[lucide--edit] size-4"></span>
</button>
</div>
<div class="mb-6">
<p class="text-2xl font-bold mb-2">{{ $currentPlan['name'] }}</p>
<p class="text-base-content/60">
<span class="text-xl font-semibold text-base-content">${{ number_format($currentPlan['price'], 2) }}</span> / {{ $currentPlan['interval'] }}
</p>
</div>
<div class="divider my-0"></div>
<div class="flex-1 space-y-3 py-4">
@foreach($currentPlan['features'] as $feature)
<div class="flex items-center gap-3">
<span class="icon-[lucide--check] size-4 text-success flex-shrink-0"></span>
<span class="text-sm">{{ $feature }}</span>
</div>
@endforeach
</div>
<div class="mt-auto pt-4">
<button onclick="changePlanModal.showModal()" class="btn btn-outline btn-block gap-2">
<span class="icon-[lucide--refresh-cw] size-4"></span>
Change Plan
</button>
</div>
</div>
</div>
<!-- Billing Info Card -->
<div class="card bg-base-100 border border-base-300 h-full">
<div class="card-body flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<span class="icon-[lucide--credit-card] size-5 text-primary"></span>
</div>
<h2 class="text-lg font-semibold">Billing</h2>
</div>
<button class="btn btn-ghost btn-sm btn-square">
<span class="icon-[lucide--edit] size-4"></span>
</button>
</div>
<!-- Payment Methods -->
<div class="mb-6">
<label class="label">
<span class="label-text font-medium">Payment Methods</span>
</label>
<div class="space-y-3">
@foreach($paymentMethods as $method)
<div class="flex items-center gap-4 p-4 bg-base-200/50 rounded-lg border {{ $method['is_default'] ? 'border-primary' : 'border-base-300' }} cursor-pointer hover:border-primary/50 transition-colors" onclick="setDefaultPaymentMethod({{ $method['id'] }})">
<div class="p-2 bg-base-100 rounded">
@if($method['brand'] === 'Visa')
<span class="icon-[logos--visa] size-6"></span>
@elseif($method['brand'] === 'Mastercard')
<span class="icon-[logos--mastercard] size-6"></span>
@elseif($method['brand'] === 'Amex')
<span class="icon-[logos--amex] size-6"></span>
@elseif($method['brand'] === 'Discover')
<span class="icon-[logos--discover] size-6"></span>
@else
<span class="icon-[lucide--credit-card] size-6 text-base-content/70"></span>
@endif
</div>
<div class="flex-1 min-w-0">
<p class="font-medium">{{ $method['brand'] }} •••• {{ $method['last4'] }}</p>
<p class="text-sm text-base-content/60">
Expires {{ str_pad($method['exp_month'], 2, '0', STR_PAD_LEFT) }}/{{ $method['exp_year'] }}
</p>
<p class="text-xs text-base-content/50 mt-1">
{{ $method['billing_address'] }}, {{ $method['billing_city'] }}, {{ $method['billing_state'] }} {{ $method['billing_zip'] }}
</p>
</div>
@if($method['is_default'])
<span class="badge badge-success badge-sm flex-shrink-0">Default</span>
@else
<span class="badge badge-ghost badge-sm flex-shrink-0">Click to set default</span>
@endif
</div>
@endforeach
</div>
</div>
<!-- Billing Contacts -->
<div class="flex-1">
<label class="label">
<span class="label-text font-medium">Billing Contacts</span>
</label>
<div class="space-y-2">
@foreach($billingContacts as $contact)
<div class="flex items-center gap-3 p-3 bg-base-200/50 rounded-lg border border-base-300">
<span class="icon-[lucide--mail] size-4 text-base-content/60 flex-shrink-0"></span>
<p class="text-sm flex-1 min-w-0 truncate">{{ $contact['email'] }}</p>
@if($contact['is_primary'])
<span class="badge badge-primary badge-xs flex-shrink-0">Primary</span>
@endif
</div>
@endforeach
@if(count($billingContacts) < 3)
<button onclick="addBillingContactModal.showModal()" class="flex items-center gap-2 p-3 w-full bg-base-200/30 rounded-lg border border-dashed border-base-300 hover:border-primary hover:bg-base-200/50 transition-colors text-sm text-base-content/60 hover:text-primary">
<span class="icon-[lucide--plus] size-4"></span>
Add Contact
</button>
@endif
</div>
</div>
<div class="mt-auto pt-6">
<button onclick="addPaymentMethodModal.showModal()" class="btn btn-outline btn-block gap-2">
<span class="icon-[lucide--plus] size-4"></span>
Add Payment Method
</button>
</div>
</div>
</div>
</div>
<!-- Billing History -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Billing History</h2>
</div>
<!-- Search and Filters -->
<div class="flex flex-col md:flex-row gap-4 mb-6">
<div class="form-control flex-1">
<div class="input-group">
<input type="text" placeholder="Search by Invoice Number" class="input input-bordered w-full" />
<button class="btn btn-primary btn-square">
<span class="icon-[lucide--search] size-5"></span>
</button>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-outline gap-2">
<span class="icon-[lucide--filter] size-4"></span>
Filters
</button>
<select class="select select-bordered">
<option>Due Date</option>
<option>Amount</option>
<option>Status</option>
</select>
<select class="select select-bordered">
<option>Status</option>
<option>Paid</option>
<option>Pending</option>
<option>Past Due</option>
<option>Overdue</option>
</select>
</div>
</div>
<!-- Invoices Table -->
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>
<div class="flex items-center gap-2">
Invoice Number
<span class="icon-[lucide--chevrons-up-down] size-4 text-base-content/40"></span>
</div>
</th>
<th>
<div class="flex items-center gap-2">
Due Date
<span class="icon-[lucide--chevrons-up-down] size-4 text-base-content/40"></span>
</div>
</th>
<th>
<div class="flex items-center gap-2">
Amount Due
<span class="icon-[lucide--chevrons-up-down] size-4 text-base-content/40"></span>
</div>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@forelse($invoices as $invoice)
<tr class="hover">
<td>
<span class="font-medium">{{ $invoice['id'] }}</span>
</td>
<td>{{ \Carbon\Carbon::parse($invoice['date'])->format('m/d/Y') }}</td>
<td>
<div class="flex items-center gap-2">
@if($invoice['status'] === 'paid')
<span class="line-through text-base-content/60 font-medium">${{ number_format($invoice['amount'], 2) }}</span>
<div class="tooltip tooltip-success" data-tip="You're awesome! Thanks for helping us keep the lights on!">
<span class="badge badge-success badge-sm cursor-help">PAID</span>
</div>
@elseif($invoice['status'] === 'pending')
<span class="font-medium text-warning">${{ number_format($invoice['amount'], 2) }}</span>
<div class="tooltip tooltip-warning" data-tip="Please make your payment soon, the natives are getting restless.">
<span class="badge badge-warning badge-sm cursor-help">PENDING</span>
</div>
@elseif($invoice['status'] === 'past_due')
<span class="font-medium text-error">${{ number_format($invoice['amount'], 2) }}</span>
<div class="tooltip tooltip-error" data-tip="We seriously can't keep going like this.. Please pay your invoice.">
<span class="badge badge-error badge-sm cursor-help">PAST DUE</span>
</div>
@else
<span class="font-medium text-error">${{ number_format($invoice['amount'], 2) }}</span>
<span class="badge badge-error badge-sm">OVERDUE</span>
@endif
</div>
</td>
<td>
<div class="flex gap-2">
<a href="{{ route('seller.business.settings.invoice.view', [$business->slug, $invoice['id']]) }}" class="btn btn-ghost btn-sm btn-square" title="View Invoice">
<span class="icon-[lucide--eye] size-4"></span>
</a>
<a href="{{ route('seller.business.settings.invoice.download', [$business->slug, $invoice['id']]) }}" class="btn btn-ghost btn-sm btn-square" title="Download PDF">
<span class="icon-[lucide--download] size-4"></span>
</a>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center py-8 text-base-content/60">
No invoices found
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between mt-4 pt-4 border-t border-base-300">
<p class="text-sm text-base-content/60">1 - 22 of 22</p>
<div class="join">
<button class="join-item btn btn-sm" disabled>
<span class="icon-[lucide--chevron-left] size-4"></span>
</button>
<button class="join-item btn btn-sm btn-active">1</button>
<button class="join-item btn btn-sm" disabled>
<span class="icon-[lucide--chevron-right] size-4"></span>
</button>
</div>
</div>
</div>
</div>
<!-- Change Plan Modal -->
<dialog id="changePlanModal" class="modal">
<div class="modal-box w-11/12 max-w-7xl">
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-2xl">Choose Your Plan</h3>
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
</button>
</form>
</div>
@php
$availablePlans = [
[
'id' => 'standard',
'name' => 'Standard',
'price' => 99,
'popular' => false,
'features' => [
'100 Products',
'Basic Analytics',
'Email Support',
'10 Team Members',
'5 GB Storage',
'Standard Security',
'Mobile App Access',
'Monthly Reports',
'API Access',
'Community Support'
]
],
[
'id' => 'business',
'name' => 'Business',
'price' => 395,
'popular' => true,
'features' => [
'Unlimited Products',
'Advanced Analytics',
'Priority Support',
'50 Team Members',
'50 GB Storage',
'Enhanced Security',
'Mobile App Access',
'Weekly Reports',
'Full API Access',
'Premium Support'
]
],
[
'id' => 'premium',
'name' => 'Premium',
'price' => 795,
'popular' => false,
'features' => [
'Unlimited Everything',
'AI-Powered Analytics',
'Dedicated Support',
'Unlimited Team Members',
'Unlimited Storage',
'Enterprise Security',
'White-Label Options',
'Real-Time Reports',
'Custom Integrations',
'24/7 Phone Support'
]
]
];
@endphp
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@foreach($availablePlans as $plan)
<div class="card bg-base-100 border-2 {{ $plan['popular'] ? 'border-primary' : 'border-base-300' }} {{ $currentPlan['name'] === 'Marketplace ' . $plan['name'] ? 'ring-2 ring-success ring-offset-2 ring-offset-base-100' : '' }} relative">
@if($plan['popular'])
<div class="badge badge-primary absolute -top-3 left-1/2 -translate-x-1/2">Most Popular</div>
@endif
@if($currentPlan['name'] === 'Marketplace ' . $plan['name'])
<div class="badge badge-success absolute -top-3 right-4">Current Plan</div>
@endif
<div class="card-body">
<h3 class="text-2xl font-bold text-center">{{ $plan['name'] }}</h3>
<div class="text-center my-4">
<span class="text-5xl font-bold">${{ number_format($plan['price']) }}</span>
<span class="text-base-content/60">/month</span>
</div>
<div class="divider my-2"></div>
<ul class="space-y-3 mb-6">
@foreach($plan['features'] as $feature)
<li class="flex items-start gap-2">
<span class="icon-[lucide--check] size-5 text-success flex-shrink-0 mt-0.5"></span>
<span class="text-sm">{{ $feature }}</span>
</li>
@endforeach
</ul>
@if($currentPlan['name'] === 'Marketplace ' . $plan['name'])
<button class="btn btn-success btn-block" disabled>
<span class="icon-[lucide--check-circle] size-4"></span>
Current Plan
</button>
@else
<form method="POST" action="{{ route('seller.business.settings.plans-and-billing.change-plan', $business->slug) }}" onsubmit="event.stopPropagation();">
@csrf
<input type="hidden" name="plan_id" value="{{ $plan['id'] }}" />
<button type="submit" class="btn {{ $plan['popular'] ? 'btn-primary' : 'btn-outline' }} btn-block">
<span class="icon-[lucide--arrow-right] size-4"></span>
Select {{ $plan['name'] }}
</button>
</form>
@endif
</div>
</div>
@endforeach
</div>
<div class="mt-6 text-center">
<p class="text-sm text-base-content/60">All plans include a 14-day money-back guarantee</p>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Add Payment Method Modal -->
<dialog id="addPaymentMethodModal" class="modal">
<div class="modal-box max-w-md">
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl">Add Payment Method</h3>
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
</button>
</form>
</div>
<form method="POST" action="#">
@csrf
<div class="space-y-4">
<!-- Card Number -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Card Number</span>
</label>
<input type="text" name="card_number" placeholder="1234 5678 9012 3456" class="input input-bordered" required maxlength="19" />
</div>
<!-- Card Holder Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Cardholder Name</span>
</label>
<input type="text" name="card_name" placeholder="John Doe" class="input input-bordered" required />
</div>
<!-- Expiration and CVV -->
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Expiration</span>
</label>
<input type="text" name="expiry" placeholder="MM/YY" class="input input-bordered" required maxlength="5" />
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">CVV</span>
</label>
<input type="text" name="cvv" placeholder="123" class="input input-bordered" required maxlength="4" />
</div>
</div>
<div class="divider text-sm">Billing Address (for AVS verification)</div>
<!-- Billing Address -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Street Address</span>
</label>
<input type="text" name="billing_address" placeholder="123 Main St" class="input input-bordered" required />
</div>
<!-- City, State, ZIP -->
<div class="grid grid-cols-6 gap-4">
<div class="form-control col-span-3">
<label class="label">
<span class="label-text font-medium">City</span>
</label>
<input type="text" name="billing_city" placeholder="Phoenix" class="input input-bordered" required />
</div>
<div class="form-control col-span-1">
<label class="label">
<span class="label-text font-medium">State</span>
</label>
<input type="text" name="billing_state" placeholder="AZ" class="input input-bordered" required maxlength="2" />
</div>
<div class="form-control col-span-2">
<label class="label">
<span class="label-text font-medium">ZIP</span>
</label>
<input type="text" name="billing_zip" placeholder="85001" class="input input-bordered" required maxlength="10" />
</div>
</div>
<!-- Set as Default -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="set_default" value="1" class="checkbox checkbox-primary" />
<span class="label-text">Set as default payment method</span>
</label>
</div>
</div>
<!-- Modal Actions -->
<div class="modal-action">
<form method="dialog">
<button type="button" class="btn btn-ghost">Cancel</button>
</form>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--credit-card] size-4"></span>
Add Card
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Add Billing Contact Modal -->
<dialog id="addBillingContactModal" class="modal">
<div class="modal-box max-w-md">
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl">Add Billing Contact</h3>
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
</button>
</form>
</div>
<form method="POST" action="#">
@csrf
<div class="space-y-4">
<!-- Email -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Address</span>
</label>
<input type="email" name="billing_email" placeholder="billing@example.com" class="input input-bordered" required />
<label class="label">
<span class="label-text-alt">This contact will receive billing notifications and invoices</span>
</label>
</div>
<!-- Set as Primary -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="is_primary" value="1" class="checkbox checkbox-primary" />
<span class="label-text">Set as primary billing contact</span>
</label>
</div>
</div>
<!-- Modal Actions -->
<div class="modal-action">
<form method="dialog">
<button type="button" class="btn btn-ghost">Cancel</button>
</form>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--user-plus] size-4"></span>
Add Contact
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Cancel Downgrade Modal -->
<dialog id="cancel_downgrade_modal" class="modal">
<div class="modal-box max-w-md">
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl">Cancel Plan Downgrade</h3>
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
</button>
</form>
</div>
<form method="POST" action="{{ route('seller.business.settings.plans-and-billing.cancel-downgrade', $business->slug) }}">
@csrf
<div class="space-y-4">
<p class="text-sm opacity-80">
Are you sure you want to cancel your scheduled plan downgrade? Your subscription will remain on the
<strong>{{ $currentPlan['name'] }}</strong> plan and you will continue to be billed
<strong>${{ number_format($currentPlan['price'], 2) }}/month</strong>.
</p>
</div>
<!-- Modal Actions -->
<div class="modal-action">
<form method="dialog">
<button type="button" class="btn btn-ghost">Keep Downgrade</button>
</form>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--check] size-4"></span>
Cancel Downgrade
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
@endsection
@push('scripts')
<script>
function setDefaultPaymentMethod(methodId) {
// TODO: Make AJAX call to set default payment method
console.log('Setting payment method ' + methodId + ' as default');
// Example implementation (uncomment when backend is ready):
/*
fetch(`/seller/{{ $business->slug }}/settings/payment-methods/${methodId}/set-default`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success notification and reload page
showToast('Payment method set as default', 'success');
setTimeout(() => window.location.reload(), 1500);
}
})
.catch(error => {
console.error('Error setting default payment method:', error);
showToast('Failed to update payment method', 'error');
});
*/
// Temporary mock notification
showToast('Payment method updated successfully', 'success');
}
function showToast(message, type = 'info') {
// Create toast container if it doesn't exist
let toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.className = 'toast toast-top toast-end z-50';
document.body.appendChild(toastContainer);
}
// Create toast alert
const toast = document.createElement('div');
const alertClass = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
const iconClass = type === 'success' ? 'icon-[lucide--check-circle]' : type === 'error' ? 'icon-[lucide--x-circle]' : 'icon-[lucide--info]';
toast.className = `alert ${alertClass}`;
toast.innerHTML = `
<span class="${iconClass} size-5"></span>
<span>${message}</span>
`;
toastContainer.appendChild(toast);
// Remove toast after 3 seconds
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
</script>
@endpush

View File

@@ -0,0 +1,467 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">My Profile</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.settings.profile', $business->slug) }}">Settings</a></li>
<li class="opacity-60">My Profile</li>
</ul>
</div>
</div>
<!-- Profile Hero Card with Avatar -->
<div class="card bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20 mb-6">
<div class="card-body">
<div class="flex flex-col md:flex-row items-center md:items-start gap-8">
<!-- Avatar Section -->
<div class="flex-shrink-0">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content w-32 h-32 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
@if(auth()->user()->avatar_path && \Storage::disk('public')->exists(auth()->user()->avatar_path))
<img src="{{ asset('storage/' . auth()->user()->avatar_path) }}" alt="{{ auth()->user()->name }}" class="rounded-full">
@else
<span class="text-5xl font-semibold">
{{ strtoupper(substr(auth()->user()->first_name ?? 'U', 0, 1)) }}{{ strtoupper(substr(auth()->user()->last_name ?? 'S', 0, 1)) }}
</span>
@endif
</div>
</div>
</div>
<!-- Profile Info -->
<div class="flex-1 text-center md:text-left">
<h2 class="text-3xl font-bold mb-2">{{ auth()->user()->name }}</h2>
<p class="text-base-content/70 mb-4">{{ auth()->user()->email }}</p>
@if(auth()->user()->position)
<div class="badge badge-lg badge-primary badge-outline">{{ auth()->user()->position }}</div>
@endif
</div>
<!-- Quick Actions -->
<div class="flex-shrink-0">
<label for="profile-photo-upload" class="btn btn-primary gap-2">
<span class="icon-[lucide--camera] size-4"></span>
<span>Change Photo</span>
</label>
</div>
</div>
</div>
</div>
<form action="{{ route('seller.business.settings.profile.update', $business->slug) }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT')
<!-- Hidden file input -->
<input type="file" id="profile-photo-upload" name="avatar" accept="image/*" class="hidden"
onchange="previewAvatar(this)">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left Column: Personal Information (2/3 width) -->
<div class="lg:col-span-2 space-y-6">
<!-- Personal Information Card -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="icon-[lucide--user] size-5 text-primary"></span>
Personal Information
</h3>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- First Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">First Name</span>
</label>
<input type="text" name="first_name" value="{{ old('first_name', auth()->user()->first_name) }}"
class="input input-bordered @error('first_name') input-error @enderror"
placeholder="John" required>
@error('first_name')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Last Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Last Name</span>
</label>
<input type="text" name="last_name" value="{{ old('last_name', auth()->user()->last_name) }}"
class="input input-bordered @error('last_name') input-error @enderror"
placeholder="Doe" required>
@error('last_name')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
<!-- Email -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Email Address</span>
</label>
<input type="email" name="email" value="{{ old('email', auth()->user()->email) }}"
class="input input-bordered @error('email') input-error @enderror"
placeholder="john.doe@example.com" required>
@error('email')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
<!-- Social Media Profiles Card -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="icon-[lucide--share-2] size-5 text-primary"></span>
Social Media Profiles
</h3>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- LinkedIn -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<span class="icon-[lucide--linkedin] size-4"></span>
<span>LinkedIn</span>
</span>
</label>
<input type="url" name="linkedin_url" value="{{ old('linkedin_url', auth()->user()->linkedin_url) }}"
class="input input-bordered @error('linkedin_url') input-error @enderror"
placeholder="https://linkedin.com/in/yourprofile">
@error('linkedin_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Twitter/X -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<span class="icon-[lucide--twitter] size-4"></span>
<span>Twitter / X</span>
</span>
</label>
<input type="url" name="twitter_url" value="{{ old('twitter_url', auth()->user()->twitter_url) }}"
class="input input-bordered @error('twitter_url') input-error @enderror"
placeholder="https://twitter.com/yourhandle">
@error('twitter_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Facebook -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<span class="icon-[lucide--facebook] size-4"></span>
<span>Facebook</span>
</span>
</label>
<input type="url" name="facebook_url" value="{{ old('facebook_url', auth()->user()->facebook_url) }}"
class="input input-bordered @error('facebook_url') input-error @enderror"
placeholder="https://facebook.com/yourprofile">
@error('facebook_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Instagram -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<span class="icon-[lucide--instagram] size-4"></span>
<span>Instagram</span>
</span>
</label>
<input type="url" name="instagram_url" value="{{ old('instagram_url', auth()->user()->instagram_url) }}"
class="input input-bordered @error('instagram_url') input-error @enderror"
placeholder="https://instagram.com/yourhandle">
@error('instagram_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- TikTok -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<span class="icon-[lucide--video] size-4"></span>
<span>TikTok</span>
</span>
</label>
<input type="url" name="tiktok_url" value="{{ old('tiktok_url', auth()->user()->tiktok_url) }}"
class="input input-bordered @error('tiktok_url') input-error @enderror"
placeholder="https://tiktok.com/@yourhandle">
@error('tiktok_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- GitHub -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<span class="icon-[lucide--github] size-4"></span>
<span>GitHub</span>
</span>
</label>
<input type="url" name="github_url" value="{{ old('github_url', auth()->user()->github_url) }}"
class="input input-bordered @error('github_url') input-error @enderror"
placeholder="https://github.com/yourusername">
@error('github_url')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Avatar Options & Actions (1/3 width) -->
<div class="space-y-6">
<!-- Avatar Options Card -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="icon-[lucide--image-plus] size-5 text-primary"></span>
Avatar Options
</h3>
<div class="space-y-4">
@if(auth()->user()->avatar_path && \Storage::disk('public')->exists(auth()->user()->avatar_path))
<div class="alert alert-info">
<span class="icon-[lucide--info] size-5"></span>
<span class="text-sm">You have a custom avatar uploaded</span>
</div>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="remove_avatar" value="1" class="checkbox checkbox-error">
<span class="label-text">Remove current avatar</span>
</label>
@endif
<div class="divider my-2">OR</div>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="use_gravatar" value="1" class="checkbox checkbox-primary"
{{ old('use_gravatar', auth()->user()->use_gravatar) ? 'checked' : '' }}>
<span class="label-text">Use Gravatar</span>
</label>
<div class="text-xs text-base-content/60">
<span class="icon-[lucide--info] size-3 inline"></span>
Gravatar uses your email to display a global avatar
</div>
</div>
</div>
</div>
<!-- Save Button Card -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<button type="submit" class="btn btn-primary btn-block gap-2">
<span class="icon-[lucide--save] size-5"></span>
Save Changes
</button>
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="btn btn-ghost btn-block gap-2">
<span class="icon-[lucide--x] size-5"></span>
Cancel
</a>
</div>
</div>
</div>
</div>
</form>
<!-- Password Change Section -->
<div class="card bg-base-100 border border-base-300 mt-6">
<div class="card-body">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="icon-[lucide--lock] size-5 text-error"></span>
Change Password
</h3>
<form action="{{ route('seller.business.settings.password.update', $business->slug) }}" method="POST">
@csrf
@method('PUT')
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Current Password -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Current Password</span>
</label>
<input type="password" name="current_password"
class="input input-bordered @error('current_password') input-error @enderror"
placeholder="Enter your current password" required>
@error('current_password')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- New Password -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">New Password</span>
</label>
<input type="password" name="password"
class="input input-bordered @error('password') input-error @enderror"
placeholder="Enter new password" required>
@error('password')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
<label class="label">
<span class="label-text-alt">Minimum 8 characters</span>
</label>
</div>
<!-- Confirm Password -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Confirm New Password</span>
</label>
<input type="password" name="password_confirmation"
class="input input-bordered"
placeholder="Confirm new password" required>
</div>
</div>
<!-- Additional Options -->
<div class="flex items-center justify-between mt-6">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="logout_other_sessions" value="1" class="checkbox checkbox-primary">
<span class="label-text">Logout from all other devices after password change</span>
</label>
<button type="submit" class="btn btn-error gap-2">
<span class="icon-[lucide--shield-check] size-4"></span>
Update Password
</button>
</div>
</form>
</div>
</div>
<!-- Login History -->
<div class="card bg-base-100 border border-base-300 mt-6">
<div class="card-body">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span class="icon-[lucide--history] size-5 text-primary"></span>
Login History
</h3>
<div class="overflow-x-auto">
@if(isset($loginHistory) && $loginHistory->isNotEmpty())
<table class="table table-zebra">
<thead>
<tr>
<th>Date & Time</th>
<th>IP Address</th>
<th>Device / Browser</th>
<th>Location</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($loginHistory as $login)
<tr>
<td>
<div class="font-medium">{{ $login->created_at->format('M d, Y') }}</div>
<div class="text-xs text-base-content/60">{{ $login->created_at->format('h:i A') }}</div>
</td>
<td class="font-mono text-sm">{{ $login->ip_address }}</td>
<td>
<div class="text-sm">{{ $login->user_agent_parsed ?? 'Unknown Device' }}</div>
</td>
<td>{{ $login->location ?? 'Unknown' }}</td>
<td>
@if($login->success)
<div class="badge badge-success badge-sm gap-1">
<span class="icon-[lucide--check] size-3"></span>
Success
</div>
@else
<div class="badge badge-error badge-sm gap-1">
<span class="icon-[lucide--x] size-3"></span>
Failed
</div>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@if($loginHistory->hasPages())
<div class="mt-4">
{{ $loginHistory->links() }}
</div>
@endif
@else
<div class="text-center py-12 text-base-content/60">
<span class="icon-[lucide--history] size-16 mx-auto mb-4 opacity-30"></span>
<p class="text-lg">No login history available</p>
</div>
@endif
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
// Preview avatar on file select
function previewAvatar(input) {
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
// Update all avatar displays
document.querySelectorAll('.avatar img, .avatar .bg-primary').forEach(el => {
if (el.tagName === 'IMG') {
el.src = e.target.result;
} else {
el.innerHTML = `<img src="${e.target.result}" alt="Preview" class="rounded-full">`;
}
});
}
reader.readAsDataURL(input.files[0]);
}
}
</script>
@endpush

View File

@@ -0,0 +1,446 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Sales Configuration</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Settings</a></li>
<li class="opacity-60">Sales Config</li>
</ul>
</div>
</div>
<form action="{{ route('seller.business.settings.sales-config.update', $business->slug) }}" method="POST">
@csrf
@method('PUT')
<!-- Order Preferences -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Preferences</h2>
<div class="space-y-4">
<!-- Separate Orders by Brand -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="separate_orders_by_brand"
value="1"
class="checkbox checkbox-primary"
{{ old('separate_orders_by_brand', $business->separate_orders_by_brand) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Separate Orders by Brand</span>
<p class="text-xs text-base-content/60">Create individual orders for each brand in multi-brand purchases</p>
</div>
</label>
</div>
<!-- Auto Increment Order IDs -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="auto_increment_order_ids"
value="1"
class="checkbox checkbox-primary"
{{ old('auto_increment_order_ids', $business->auto_increment_order_ids) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Auto Increment Order IDs</span>
<p class="text-xs text-base-content/60">Automatically generate sequential order numbers</p>
</div>
</label>
</div>
<!-- Show Mark as Paid -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="show_mark_as_paid"
value="1"
class="checkbox checkbox-primary"
{{ old('show_mark_as_paid', $business->show_mark_as_paid ?? true) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Show Mark as Paid</span>
<p class="text-xs text-base-content/60">Display "Mark as Paid" option in order management</p>
</div>
</label>
</div>
<!-- Display CRM License on Orders -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="display_crm_license_on_orders"
value="1"
class="checkbox checkbox-primary"
{{ old('display_crm_license_on_orders', $business->display_crm_license_on_orders) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Display CRM License on Orders</span>
<p class="text-xs text-base-content/60">Show business license number on order documents</p>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Financial Settings -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Financial Settings</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Order Minimum -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Order Minimum</span>
</label>
<label class="input-group">
<span class="bg-base-200">$</span>
<input
type="number"
name="order_minimum"
value="{{ old('order_minimum', $business->order_minimum) }}"
class="input input-bordered w-full @error('order_minimum') input-error @enderror"
placeholder="0.00"
step="0.01"
min="0"
/>
</label>
<label class="label">
<span class="label-text-alt">Minimum order amount required</span>
</label>
@error('order_minimum')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Default Shipping Charge -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Default Shipping Charge</span>
</label>
<label class="input-group">
<span class="bg-base-200">$</span>
<input
type="number"
name="default_shipping_charge"
value="{{ old('default_shipping_charge', $business->default_shipping_charge) }}"
class="input input-bordered w-full @error('default_shipping_charge') input-error @enderror"
placeholder="0.00"
step="0.01"
min="0"
/>
</label>
<label class="label">
<span class="label-text-alt">Standard shipping fee per order</span>
</label>
@error('default_shipping_charge')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Free Shipping Minimum -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Free Shipping Minimum</span>
</label>
<label class="input-group">
<span class="bg-base-200">$</span>
<input
type="number"
name="free_shipping_minimum"
value="{{ old('free_shipping_minimum', $business->free_shipping_minimum) }}"
class="input input-bordered w-full @error('free_shipping_minimum') input-error @enderror"
placeholder="0.00"
step="0.01"
min="0"
/>
</label>
<label class="label">
<span class="label-text-alt">Order amount for free shipping</span>
</label>
@error('free_shipping_minimum')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
<!-- Order Documents -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Documents</h2>
<!-- Order Disclaimer -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">Order Disclaimer</span>
<span class="label-text-alt text-base-content/60">Optional</span>
</label>
<textarea
name="order_disclaimer"
rows="4"
class="textarea textarea-bordered @error('order_disclaimer') textarea-error @enderror"
placeholder="Enter any disclaimer text to appear on orders..."
>{{ old('order_disclaimer', $business->order_disclaimer) }}</textarea>
<label class="label">
<span class="label-text-alt">Displayed on order confirmations and invoices</span>
</label>
@error('order_disclaimer')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Order Invoice Footer -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Order Invoice Footer Copy</span>
<span class="label-text-alt text-base-content/60">Optional</span>
</label>
<textarea
name="order_invoice_footer"
rows="3"
class="textarea textarea-bordered @error('order_invoice_footer') textarea-error @enderror"
placeholder="Enter footer text for invoices..."
>{{ old('order_invoice_footer', $business->order_invoice_footer) }}</textarea>
<label class="label">
<span class="label-text-alt">Appears at the bottom of all invoices</span>
</label>
@error('order_invoice_footer')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Order Management -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Management</h2>
<!-- Prevent Order Editing -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Prevent Order Editing</span>
</label>
<select
name="prevent_order_editing"
class="select select-bordered @error('prevent_order_editing') select-error @enderror"
>
<option value="never" {{ old('prevent_order_editing', $business->prevent_order_editing ?? 'never') == 'never' ? 'selected' : '' }}>
Never - Always allow editing
</option>
<option value="after_approval" {{ old('prevent_order_editing', $business->prevent_order_editing) == 'after_approval' ? 'selected' : '' }}>
After Approval - Lock once approved
</option>
<option value="after_fulfillment" {{ old('prevent_order_editing', $business->prevent_order_editing) == 'after_fulfillment' ? 'selected' : '' }}>
After Fulfillment - Lock once fulfilled
</option>
<option value="always" {{ old('prevent_order_editing', $business->prevent_order_editing) == 'always' ? 'selected' : '' }}>
Always - Prevent all editing
</option>
</select>
<label class="label">
<span class="label-text-alt">Control when orders can no longer be edited</span>
</label>
@error('prevent_order_editing')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Arizona Compliance Features -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Arizona Compliance Features</h2>
<div class="space-y-4">
<!-- Require Patient Count -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="az_require_patient_count"
value="1"
class="checkbox checkbox-primary"
{{ old('az_require_patient_count', $business->az_require_patient_count) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Require Patient Count</span>
<p class="text-xs text-base-content/60">Require customer to provide patient count with orders (medical licenses)</p>
</div>
</label>
</div>
<!-- Require Allotment Verification -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="az_require_allotment_verification"
value="1"
class="checkbox checkbox-primary"
{{ old('az_require_allotment_verification', $business->az_require_allotment_verification) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Require Allotment Verification</span>
<p class="text-xs text-base-content/60">Verify customer allotment availability before order confirmation</p>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Combined Use Payable to Info (Invoice Settings) -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-2">Invoice Payable Info</h2>
<p class="text-sm text-base-content/60 mb-4">Accounts Payable information for your Company's Combined or Cannabis license orders.<br>If not entered, the default Company name and address will be used.</p>
<div class="space-y-6">
<!-- Company Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Company Name</span>
</label>
<input
type="text"
name="invoice_payable_company_name"
value="{{ old('invoice_payable_company_name', $business->invoice_payable_company_name) }}"
class="input input-bordered @error('invoice_payable_company_name') input-error @enderror"
placeholder="Life Changers Investments DBA Leopard AZ"
/>
@error('invoice_payable_company_name')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Address -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Address</span>
</label>
<input
type="text"
name="invoice_payable_address"
value="{{ old('invoice_payable_address', $business->invoice_payable_address) }}"
class="input input-bordered @error('invoice_payable_address') input-error @enderror"
placeholder="1225 W Deer Valley"
/>
@error('invoice_payable_address')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- City -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">City</span>
</label>
<input
type="text"
name="invoice_payable_city"
value="{{ old('invoice_payable_city', $business->invoice_payable_city) }}"
class="input input-bordered @error('invoice_payable_city') input-error @enderror"
placeholder="Phoenix"
/>
@error('invoice_payable_city')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- State -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">State</span>
</label>
<select
name="invoice_payable_state"
class="select select-bordered @error('invoice_payable_state') select-error @enderror"
>
<option value="">Select state</option>
<option value="AZ" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'AZ' ? 'selected' : '' }}>Arizona</option>
<option value="CA" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'CA' ? 'selected' : '' }}>California</option>
<option value="CO" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'CO' ? 'selected' : '' }}>Colorado</option>
<option value="NV" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'NV' ? 'selected' : '' }}>Nevada</option>
<option value="NM" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'NM' ? 'selected' : '' }}>New Mexico</option>
<option value="OR" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'OR' ? 'selected' : '' }}>Oregon</option>
<option value="WA" {{ old('invoice_payable_state', $business->invoice_payable_state) == 'WA' ? 'selected' : '' }}>Washington</option>
</select>
@error('invoice_payable_state')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Zip Code -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Zip Code</span>
</label>
<input
type="text"
name="invoice_payable_zipcode"
value="{{ old('invoice_payable_zipcode', $business->invoice_payable_zipcode) }}"
class="input input-bordered @error('invoice_payable_zipcode') input-error @enderror"
placeholder="85027"
maxlength="10"
/>
@error('invoice_payable_zipcode')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="btn btn-ghost gap-2">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Settings
</button>
</div>
</form>
@endsection

View File

@@ -0,0 +1,267 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-2">
<span class="icon-[lucide--user-cog] size-6"></span>
<p class="text-lg font-medium">Edit User Permissions</p>
</div>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.settings.users', $business->slug) }}">Users</a></li>
<li class="opacity-60">Edit Permissions</li>
</ul>
</div>
</div>
@if(session('success'))
<div class="alert alert-success mb-6">
<span class="icon-[lucide--check-circle] size-5"></span>
<span>{{ session('success') }}</span>
</div>
@endif
<!-- User Info Card -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<div class="flex items-start gap-4">
<div class="avatar placeholder">
<div class="bg-primary/10 text-primary rounded-full w-16 h-16">
<span class="text-2xl font-bold">
{{ strtoupper(substr($user->first_name ?? 'U', 0, 1) . substr($user->last_name ?? '', 0, 1)) }}
</span>
</div>
</div>
<div class="flex-1">
<h2 class="text-2xl font-bold">{{ $user->first_name }} {{ $user->last_name }}</h2>
<p class="text-base-content/60">{{ $user->email }}</p>
<div class="flex gap-2 mt-2">
@if($business->owner_user_id === $user->id)
<span class="badge badge-primary gap-1">
<span class="icon-[lucide--crown] size-3"></span>
Business Owner
</span>
@elseif($user->pivot->is_primary)
<span class="badge badge-primary">Primary Contact</span>
@endif
@if($user->pivot->contact_type)
<span class="badge badge-outline">
{{ ucwords(str_replace('_', ' ', $user->pivot->contact_type)) }}
</span>
@endif
</div>
</div>
</div>
</div>
</div>
<!-- Owner Warning -->
@if($isOwner)
<div class="alert alert-info mb-6">
<span class="icon-[lucide--shield-check] size-5"></span>
<span>This user is the business owner and has full access to all features. Permissions cannot be modified.</span>
</div>
@endif
<!-- Permissions Form -->
<form action="{{ route('seller.business.settings.users.update', [$business->slug, $user]) }}" method="POST" id="permissions-form">
@csrf
@method('PATCH')
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h3 class="card-title mb-4">
<span class="icon-[lucide--shield] size-5"></span>
User Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Position -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Position</span>
</label>
<input type="text"
name="position"
value="{{ old('position', $user->position) }}"
class="input input-bordered"
placeholder="e.g. Sales Manager"
{{ $isOwner ? 'disabled' : '' }}>
<label class="label">
<span class="label-text-alt">Job title or role in the company</span>
</label>
</div>
<!-- Company (from users table) -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Company</span>
</label>
<input type="text"
name="company"
value="{{ old('company', $user->company) }}"
class="input input-bordered"
placeholder="Company name"
{{ $isOwner ? 'disabled' : '' }}>
<label class="label">
<span class="label-text-alt">Company affiliation</span>
</label>
</div>
<!-- Contact Type -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Contact Type</span>
</label>
<select name="contact_type" class="select select-bordered" {{ $isOwner ? 'disabled' : '' }}>
<option value="">None</option>
<option value="primary" {{ $user->pivot->contact_type === 'primary' ? 'selected' : '' }}>Primary Contact</option>
<option value="billing" {{ $user->pivot->contact_type === 'billing' ? 'selected' : '' }}>Billing Contact</option>
<option value="technical" {{ $user->pivot->contact_type === 'technical' ? 'selected' : '' }}>Technical Contact</option>
<option value="owner" {{ $user->pivot->contact_type === 'owner' ? 'selected' : '' }}>Owner</option>
<option value="manager" {{ $user->pivot->contact_type === 'manager' ? 'selected' : '' }}>Manager</option>
<option value="brand_manager" {{ $user->pivot->contact_type === 'brand_manager' ? 'selected' : '' }}>Brand Manager</option>
</select>
<label class="label">
<span class="label-text-alt">Type of contact in relation to the business</span>
</label>
</div>
<!-- Role (from pivot table) -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Role</span>
</label>
<input type="text"
name="role"
value="{{ old('role', $user->pivot->role) }}"
class="input input-bordered"
placeholder="e.g. administrator"
{{ $isOwner ? 'disabled' : '' }}>
<label class="label">
<span class="label-text-alt">User role in the system</span>
</label>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h3 class="card-title mb-4">
<span class="icon-[lucide--key] size-5"></span>
Permissions & Access
</h3>
<!-- Hidden role_template field (kept in database but not shown to users) -->
<input type="hidden" name="role_template" value="{{ $user->pivot->role_template }}">
<!-- Permission Categories -->
<div class="space-y-4">
@foreach($permissionCategories as $categoryKey => $category)
<div class="card bg-base-200/50 border border-base-300">
<div class="card-body p-4">
<!-- Category Header -->
<div class="flex items-center justify-between mb-3">
<h4 class="font-semibold flex items-center gap-2">
<span class="icon-[{{ $category['icon'] }}] size-5"></span>
{{ $category['name'] }}
</h4>
@if(!$isOwner)
<button type="button"
class="btn btn-xs btn-ghost enable-all-toggle"
data-category="{{ $categoryKey }}"
onclick="toggleCategoryPermissions('{{ $categoryKey }}')">
<span class="icon-[lucide--check-square] size-3"></span>
Enable All
</button>
@endif
</div>
<!-- Permissions Grid (2 columns) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
@foreach($category['permissions'] as $permKey => $permData)
<label class="flex items-start gap-2 p-2 rounded hover:bg-base-100 cursor-pointer permission-item"
data-category="{{ $categoryKey }}">
<input type="checkbox"
name="permissions[]"
value="{{ $permKey }}"
class="checkbox checkbox-sm checkbox-primary mt-0.5"
{{ is_array($user->pivot->permissions) && in_array($permKey, $user->pivot->permissions) ? 'checked' : '' }}
{{ $isOwner ? 'disabled checked' : '' }}>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ $permData['name'] }}</div>
<div class="text-xs text-base-content/60 line-clamp-2">{{ $permData['description'] }}</div>
</div>
</label>
@endforeach
</div>
</div>
</div>
@endforeach
</div>
<!-- Action Buttons -->
<div class="flex justify-between items-center mt-6 pt-6 border-t">
<a href="{{ route('seller.business.settings.users', $business->slug) }}" class="btn btn-ghost">
<span class="icon-[lucide--arrow-left] size-4"></span>
Back to Users
</a>
@if(!$isOwner)
<button type="submit" class="btn btn-primary">
<span class="icon-[lucide--save] size-4"></span>
Save Changes
</button>
@endif
</div>
</div>
</div>
</form>
@push('scripts')
<script>
/**
* Toggle all permissions in a category
*/
function toggleCategoryPermissions(categoryKey) {
const checkboxes = document.querySelectorAll(`.permission-item[data-category="${categoryKey}"] input[type="checkbox"]`);
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
checkboxes.forEach(cb => {
cb.checked = !allChecked;
});
updateEnableAllButtons();
}
/**
* Update "Enable All" button states based on current selections
*/
function updateEnableAllButtons() {
document.querySelectorAll('.enable-all-toggle').forEach(button => {
const categoryKey = button.dataset.category;
const checkboxes = document.querySelectorAll(`.permission-item[data-category="${categoryKey}"] input[type="checkbox"]`);
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
if (allChecked) {
button.innerHTML = '<span class="icon-[lucide--x-square] size-3"></span> Disable All';
} else {
button.innerHTML = '<span class="icon-[lucide--check-square] size-3"></span> Enable All';
}
});
}
/**
* Update "Enable All" buttons when individual checkboxes change
*/
document.querySelectorAll('#permissions-form input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', updateEnableAllButtons);
});
// Initialize button states on page load
updateEnableAllButtons();
</script>
@endpush
@endsection

View File

@@ -2,27 +2,879 @@
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Users</p>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Company</a></li>
<li class="opacity-80">Users</li>
</ul>
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Manage Users</h1>
<p class="text-sm text-base-content/60 mt-1">Manage the permissions for your users.</p>
</div>
<button type="button" class="btn btn-primary gap-2" onclick="add_user_modal.showModal()">
<span class="icon-[lucide--plus] size-4"></span>
Add users
</button>
</div>
<!-- Search and Filter Section -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<form method="GET" action="{{ route('seller.business.settings.users', $business->slug) }}" class="space-y-4">
<!-- Main Search Bar -->
<div class="form-control">
<div class="input-group">
<span class="flex items-center px-4 bg-base-200">
<span class="icon-[lucide--search] size-4"></span>
</span>
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Search users by name or email..."
class="input input-bordered flex-1"
/>
</div>
</div>
<!-- Filter Selectors -->
<div class="flex flex-wrap gap-3">
<!-- Account Type Filter -->
<div class="form-control flex-1 min-w-[200px]">
<select name="account_type" class="select select-bordered select-sm">
<option value="">All Account Types</option>
<option value="company-owner" {{ request('account_type') === 'company-owner' ? 'selected' : '' }}>Owner</option>
<option value="company-manager" {{ request('account_type') === 'company-manager' ? 'selected' : '' }}>Manager</option>
<option value="company-user" {{ request('account_type') === 'company-user' ? 'selected' : '' }}>Staff</option>
<option value="company-sales" {{ request('account_type') === 'company-sales' ? 'selected' : '' }}>Sales</option>
<option value="company-accounting" {{ request('account_type') === 'company-accounting' ? 'selected' : '' }}>Accounting</option>
<option value="company-manufacturing" {{ request('account_type') === 'company-manufacturing' ? 'selected' : '' }}>Manufacturing</option>
<option value="company-processing" {{ request('account_type') === 'company-processing' ? 'selected' : '' }}>Processing</option>
</select>
</div>
<!-- Last Login Date Range -->
<div class="form-control flex-1 min-w-[150px]">
<input
type="date"
name="last_login_start"
value="{{ request('last_login_start') }}"
placeholder="Login from"
class="input input-bordered input-sm"
/>
</div>
<div class="form-control flex-1 min-w-[150px]">
<input
type="date"
name="last_login_end"
value="{{ request('last_login_end') }}"
placeholder="Login to"
class="input input-bordered input-sm"
/>
</div>
<!-- Action Buttons -->
<div class="flex gap-2">
<button type="submit" class="btn btn-primary btn-sm gap-2">
<span class="icon-[lucide--filter] size-4"></span>
Apply Filters
</button>
<a href="{{ route('seller.business.settings.users', $business->slug) }}" class="btn btn-ghost btn-sm">
Clear
</a>
</div>
</div>
</form>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">User Management</h2>
<p class="text-base-content/60">Manage users and permissions for your business.</p>
<!-- Users Table -->
@if($users->count() > 0)
<div class="card bg-base-100 border border-base-300">
<div class="overflow-x-auto">
<table class="table table-lg">
<thead class="bg-base-200">
<tr>
<th>
<div class="flex items-center gap-2">
<span class="icon-[lucide--user] size-4"></span>
Name
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[lucide--mail] size-4"></span>
Email
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[lucide--shield] size-4"></span>
Role
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[lucide--clock] size-4"></span>
Last Login
</div>
</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach($users as $user)
<tr class="hover:bg-base-200/50 transition-colors">
<td>
<div class="font-semibold">{{ $user->name }}</div>
</td>
<td>
<div class="text-sm">{{ $user->email }}</div>
</td>
<td>
@if($user->roles->isNotEmpty())
@php
$roleName = $user->roles->first()->name;
$displayName = match($roleName) {
'company-owner' => 'Owner',
'company-manager' => 'Manager',
'company-user' => 'Staff',
'company-sales' => 'Sales',
'company-accounting' => 'Accounting',
'company-manufacturing' => 'Manufacturing',
'company-processing' => 'Processing',
'buyer-owner' => 'Buyer Owner',
'buyer-manager' => 'Buyer Manager',
'buyer-user' => 'Buyer Staff',
default => ucwords(str_replace('-', ' ', $roleName))
};
@endphp
<div class="badge badge-ghost badge-sm">
{{ $displayName }}
</div>
@else
<span class="text-base-content/40"></span>
@endif
</td>
<td>
@if($user->last_login_at)
<div class="text-sm">{{ $user->last_login_at->format('M d, Y') }}</div>
<div class="text-xs text-base-content/60">{{ $user->last_login_at->format('g:i A') }}</div>
@else
<span class="text-base-content/40">Never</span>
@endif
</td>
<td>
<div class="flex gap-2 justify-end">
<a href="{{ route('seller.business.settings.users.edit', [$business->slug, $user->uuid]) }}" class="btn btn-sm btn-ghost gap-2">
<span class="icon-[lucide--pencil] size-4"></span>
Edit
</a>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4">
<p class="text-sm text-base-content/60">This page is under construction.</p>
<!-- Pagination -->
@if($users->hasPages())
<div class="flex justify-center border-t border-base-300 p-4 bg-base-50">
{{ $users->links() }}
</div>
@endif
</div>
@else
<!-- Empty State -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<div class="text-center py-8 text-base-content/60">
<span class="icon-[lucide--users] size-12 mx-auto mb-2 opacity-30"></span>
<p class="text-sm">
@if(request()->hasAny(['search', 'account_type', 'last_login_start', 'last_login_end']))
No users match your filters. Try adjusting your search criteria.
@else
No users found. Add your first user to get started.
@endif
</p>
</div>
</div>
</div>
@endif
<!-- Add User Modal -->
<dialog id="add_user_modal" class="modal">
<div class="modal-box max-w-2xl max-h-[90vh] overflow-y-auto">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg mb-6">Add New User</h3>
<form method="POST" action="{{ route('seller.business.settings.users.invite', $business->slug) }}">
@csrf
<!-- Account Information Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Information</h4>
<div class="space-y-4">
<!-- Email -->
<div>
<label class="label">
<span class="label-text font-medium">Email</span>
</label>
<input
type="email"
name="email"
required
class="input input-bordered w-full"
placeholder="user@example.com"
/>
<label class="label">
<span class="label-text-alt text-xs text-base-content/60">Add a new or existing user</span>
</label>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">First Name</span>
</label>
<input
type="text"
name="first_name"
required
class="input input-bordered w-full"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Last Name</span>
</label>
<input
type="text"
name="last_name"
required
class="input input-bordered w-full"
/>
</div>
</div>
<!-- Phone Number -->
<div>
<label class="label">
<span class="label-text font-medium">Phone number</span>
</label>
<input
type="tel"
name="phone"
class="input input-bordered w-full"
placeholder="(XXX) XXX-XXXX"
/>
</div>
<!-- Position -->
<div>
<label class="label">
<span class="label-text font-medium">Position</span>
</label>
<input
type="text"
name="position"
class="input input-bordered w-full"
/>
</div>
<!-- Company (Read-only) -->
<div>
<label class="label">
<span class="label-text font-medium">Company</span>
</label>
<input
type="text"
value="{{ $business->name }}"
readonly
class="input input-bordered w-full bg-base-200 text-base-content/60"
/>
</div>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Account Type Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Type</h4>
<div class="grid grid-cols-2 gap-3">
<label class="cursor-pointer">
<input type="radio" name="role" value="company-user" class="peer sr-only" checked />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Staff</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-sales" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Sales</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-accounting" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Accounting</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-manufacturing" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Manufacturing</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-processing" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Processing</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-manager" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Manager</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-owner" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Owner</div>
</div>
</label>
</div>
<div class="mt-4 p-4 bg-base-200 rounded-box">
<label class="label cursor-pointer justify-start gap-3 p-0">
<input type="checkbox" name="is_point_of_contact" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Is a point of contact</span>
<p class="text-xs text-base-content/60 mt-1">
If enabled, this user will be automatically listed as a contact for buyers, with their name, job title, email, and phone number visible. If the user is a sales rep, you cannot disable this setting.
</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Note about permissions -->
<div class="alert bg-base-200 border-base-300 mb-6">
<span class="icon-[lucide--info] size-5 text-base-content/60"></span>
<div class="text-sm">
<p class="font-semibold">Role-based Access</p>
<p class="text-base-content/70">Permissions are determined by the selected account type. Granular permission controls will be available in a future update.</p>
</div>
</div>
<div class="modal-action">
<button type="button" onclick="add_user_modal.close()" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary gap-2">
Add user
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Edit User Modals (one per user) -->
@foreach($users as $user)
@php
$nameParts = explode(' ', $user->name, 2);
$firstName = $nameParts[0] ?? '';
$lastName = $nameParts[1] ?? '';
$userRole = $user->roles->first()?->name ?? 'company-user';
$pivot = $user->pivot ?? null;
$isPointOfContact = $pivot && $pivot->contact_type === 'primary';
@endphp
<dialog id="edit_user_modal_{{ $user->id }}" class="modal">
<div class="modal-box max-w-4xl h-[90vh] flex flex-col p-0">
<div class="flex-shrink-0 p-6 pb-4 border-b border-base-300">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg">Edit User</h3>
</div>
<form method="POST" action="{{ route('seller.business.settings.users.update', ['business' => $business->slug, 'user' => $user->id]) }}" class="flex flex-col flex-1 min-h-0">
@csrf
@method('PATCH')
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- Account Information Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Information</h4>
<div class="space-y-4">
<!-- Email -->
<div>
<label class="label">
<span class="label-text font-medium">Email</span>
</label>
<input
type="email"
name="email"
value="{{ $user->email }}"
required
class="input input-bordered w-full"
/>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">First Name</span>
</label>
<input
type="text"
name="first_name"
value="{{ $firstName }}"
required
class="input input-bordered w-full"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Last Name</span>
</label>
<input
type="text"
name="last_name"
value="{{ $lastName }}"
required
class="input input-bordered w-full"
/>
</div>
</div>
<!-- Phone Number -->
<div>
<label class="label">
<span class="label-text font-medium">Phone number</span>
</label>
<input
type="tel"
name="phone"
value="{{ $user->phone }}"
class="input input-bordered w-full"
placeholder="(XXX) XXX-XXXX"
/>
</div>
<!-- Position -->
<div>
<label class="label">
<span class="label-text font-medium">Position</span>
</label>
<input
type="text"
name="position"
value="{{ $pivot->position ?? '' }}"
class="input input-bordered w-full"
/>
</div>
<!-- Company (Read-only) -->
<div>
<label class="label">
<span class="label-text font-medium">Company</span>
</label>
<input
type="text"
value="{{ $business->name }}"
readonly
class="input input-bordered w-full bg-base-200 text-base-content/60"
/>
</div>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Account Type Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Type</h4>
<div>
<label class="label">
<span class="label-text font-medium">Role</span>
</label>
<select name="role" class="select select-bordered w-full" required>
<option value="company-user" {{ $userRole === 'company-user' ? 'selected' : '' }}>Staff</option>
<option value="company-sales" {{ $userRole === 'company-sales' ? 'selected' : '' }}>Sales</option>
<option value="company-accounting" {{ $userRole === 'company-accounting' ? 'selected' : '' }}>Accounting</option>
<option value="company-manufacturing" {{ $userRole === 'company-manufacturing' ? 'selected' : '' }}>Manufacturing</option>
<option value="company-processing" {{ $userRole === 'company-processing' ? 'selected' : '' }}>Processing</option>
<option value="company-manager" {{ $userRole === 'company-manager' ? 'selected' : '' }}>Manager</option>
<option value="company-owner" {{ $userRole === 'company-owner' ? 'selected' : '' }}>Owner</option>
</select>
</div>
<div class="mt-4 p-4 bg-base-200 rounded-box">
<label class="label cursor-pointer justify-start gap-3 p-0">
<input type="checkbox" name="is_point_of_contact" class="checkbox checkbox-sm" {{ $isPointOfContact ? 'checked' : '' }} />
<div class="flex-1">
<span class="label-text font-medium">Is a point of contact</span>
<p class="text-xs text-base-content/60 mt-1">
If enabled, this user will be automatically listed as a contact for buyers, with their name, job title, email, and phone number visible.
</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Permissions Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base flex items-center gap-2">
<span class="icon-[lucide--shield-check] size-5"></span>
Permissions
</h4>
<!-- Order & Inventory Management -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="icon-[lucide--package] size-5"></span>
<h5 class="font-semibold">Order & Inventory Management</h5>
</div>
<label class="label cursor-pointer gap-2 p-0">
<span class="label-text text-sm">Enable All</span>
<input type="checkbox" class="toggle toggle-sm toggle-primary" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_inventory" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage inventory</span>
<p class="text-xs text-base-content/60 mt-0.5">Create, edit, and archive products and varieties</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="edit_prices" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Edit prices</span>
<p class="text-xs text-base-content/60 mt-0.5">Manipulate product pricing and apply blanket discounts</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_orders_received" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage Orders Received</span>
<p class="text-xs text-base-content/60 mt-0.5">Update order statuses, create manual orders</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_billing" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage billing</span>
<p class="text-xs text-base-content/60 mt-0.5">Manage billing information for LeafLink fees (Admin only)</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Customer Management -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="icon-[lucide--users] size-5"></span>
<h5 class="font-semibold">Customer Management</h5>
</div>
<label class="label cursor-pointer gap-2 p-0">
<span class="label-text text-sm">Enable All</span>
<input type="checkbox" class="toggle toggle-sm toggle-primary" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_customers" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage Customers and Contacts</span>
<p class="text-xs text-base-content/60 mt-0.5">Manage customer records, apply discounts and shipping charges</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="access_sales_reports" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Access sales reports</span>
<p class="text-xs text-base-content/60 mt-0.5">Access and download all sales reports and dashboards</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="export_crm" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Export CRM</span>
<p class="text-xs text-base-content/60 mt-0.5">Export customers/contacts as a CSV file</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Logistics -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[lucide--truck] size-5"></span>
<h5 class="font-semibold">Logistics</h5>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_fulfillment" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage fulfillment</span>
<p class="text-xs text-base-content/60 mt-0.5">Access to Fulfillment & Shipment pages and update statuses</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Email -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[lucide--mail] size-5"></span>
<h5 class="font-semibold">Email</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="receive_order_emails" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Receive New & Accepted order emails</span>
<p class="text-xs text-base-content/60 mt-0.5">Checking this box enables user to receive New & Accepted order emails for all customers</p>
</div>
</label>
<div class="alert bg-base-200 border-base-300">
<span class="icon-[lucide--info] size-5"></span>
<div class="text-sm">
By default, all users receive emails for customers in which they are the assigned sales rep
</div>
</div>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Data Control -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[lucide--lock] size-5"></span>
<h5 class="font-semibold">Data Control</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="limit_to_assigned_customers" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Limit access to assigned customers</span>
<p class="text-xs text-base-content/60 mt-0.5">When enabled, this user can only view/manage customers, contacts, and orders assigned to them</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Other Settings -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[lucide--settings] size-5"></span>
<h5 class="font-semibold">Other Settings</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="access_developer_options" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Access Developer Options</span>
<p class="text-xs text-base-content/60 mt-0.5">Create and manage Webhooks and API Keys</p>
</div>
</label>
</div>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Danger Zone -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base text-error">Danger Zone</h4>
<button type="button" class="btn btn-outline btn-error gap-2">
<span class="icon-[lucide--user-minus] size-4"></span>
Deactivate User
</button>
</div>
</div>
<div class="flex-shrink-0 border-t border-base-300 p-6 pt-4">
<div class="flex gap-3 justify-end">
<button type="button" onclick="edit_user_modal_{{ $user->id }}.close()" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Changes
</button>
</div>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script>
function openEditModal{{ $user->id }}() {
document.getElementById('edit_user_modal_{{ $user->id }}').showModal();
}
</script>
@endforeach
<!-- User Login History Audit Table -->
<div class="card bg-base-100 border border-base-300 mt-8">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold flex items-center gap-2">
<span class="icon-[lucide--shield-check] size-5 text-primary"></span>
User Login History
</h2>
<p class="text-sm text-base-content/60 mt-1">Audit log of user authentication activity</p>
</div>
</div>
@php
// TODO: Replace with actual login history data from controller
// This requires a login_history table or audit_logs table
// Sample data for development/testing
$loginHistory = collect([
(object) [
'user' => (object) ['name' => 'John Smith', 'email' => 'john@cannabrands.biz'],
'created_at' => now()->subHours(2),
'ip_address' => '192.168.1.100',
'user_agent_parsed' => 'Chrome 120 on macOS',
'location' => 'Phoenix, AZ',
'success' => true,
],
(object) [
'user' => (object) ['name' => 'Sarah Johnson', 'email' => 'sarah@cannabrands.biz'],
'created_at' => now()->subHours(5),
'ip_address' => '192.168.1.101',
'user_agent_parsed' => 'Firefox 121 on Windows 11',
'location' => 'Scottsdale, AZ',
'success' => true,
],
(object) [
'user' => (object) ['name' => 'Mike Davis', 'email' => 'mike@cannabrands.biz'],
'created_at' => now()->subDay(),
'ip_address' => '192.168.1.102',
'user_agent_parsed' => 'Safari 17 on iPhone',
'location' => 'Tempe, AZ',
'success' => true,
],
(object) [
'user' => (object) ['name' => 'Unknown User', 'email' => 'test@example.com'],
'created_at' => now()->subDay()->subHours(3),
'ip_address' => '203.0.113.42',
'user_agent_parsed' => 'Chrome 120 on Windows 10',
'location' => 'Unknown',
'success' => false,
],
(object) [
'user' => (object) ['name' => 'Emily Rodriguez', 'email' => 'emily@cannabrands.biz'],
'created_at' => now()->subDays(2),
'ip_address' => '192.168.1.103',
'user_agent_parsed' => 'Edge 120 on Windows 11',
'location' => 'Mesa, AZ',
'success' => true,
],
]);
@endphp
@if($loginHistory->isNotEmpty())
<div class="overflow-x-auto">
<table class="table table-sm">
<thead class="bg-base-200">
<tr>
<th>User</th>
<th>Date & Time</th>
<th>IP Address</th>
<th>Device / Browser</th>
<th>Location</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($loginHistory as $log)
<tr class="hover:bg-base-200/50">
<td>
<div class="font-medium">{{ $log->user->name }}</div>
<div class="text-xs text-base-content/60">{{ $log->user->email }}</div>
</td>
<td>
<div class="text-sm">{{ $log->created_at->format('M d, Y') }}</div>
<div class="text-xs text-base-content/60">{{ $log->created_at->format('g:i A') }}</div>
</td>
<td class="font-mono text-xs">{{ $log->ip_address }}</td>
<td>
<div class="text-sm">{{ $log->user_agent_parsed ?? 'Unknown' }}</div>
</td>
<td class="text-sm">{{ $log->location ?? '—' }}</td>
<td>
@if($log->success)
<div class="badge badge-success badge-sm gap-1">
<span class="icon-[lucide--check] size-3"></span>
Success
</div>
@else
<div class="badge badge-error badge-sm gap-1">
<span class="icon-[lucide--x] size-3"></span>
Failed
</div>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-12 text-base-content/60">
<span class="icon-[lucide--shield-check] size-12 mx-auto mb-3 opacity-30"></span>
<p class="text-sm font-medium">No login history available</p>
<p class="text-xs mt-1">User authentication logs will appear here once the audit system is configured.</p>
</div>
@endif
</div>
</div>
@endsection

View File

@@ -0,0 +1,684 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-2">
<span class="icon-[lucide--webhook] size-6"></span>
<p class="text-lg font-medium">Webhooks / API</p>
</div>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a>Settings</a></li>
<li class="opacity-60">Webhooks / API</li>
</ul>
</div>
</div>
@php
// Mock data - replace with actual data from controller
$webhooks = collect([
[
'id' => 1,
'name' => 'Order Notification',
'url' => 'https://example.com/webhooks/orders',
'method' => 'POST',
'types' => ['order.created', 'order.updated'],
'enabled' => true,
'last_triggered' => '2025-01-08 14:32:00',
'status' => 'success'
],
]);
$companyWebhookKey = '1d08cfec92b9ae96e338a9b0350bce034fdf5fdd';
$apiKey = '6b9e7b79b4c1994f250a9f394ed6a62adb5d93da';
$username = 'kelly@cannabrands.biz';
$companySlug = $business->slug;
$companyId = $business->id;
// Available webhook event types
$eventTypes = [
'Orders' => [
'order.created' => 'Order Created',
'order.updated' => 'Order Updated',
'order.fulfilled' => 'Order Fulfilled',
'order.cancelled' => 'Order Cancelled',
'order.refunded' => 'Order Refunded',
],
'Products' => [
'product.created' => 'Product Created',
'product.updated' => 'Product Updated',
'product.deleted' => 'Product Deleted',
'inventory.low' => 'Low Inventory Alert',
'inventory.out' => 'Out of Stock Alert',
],
'Customers' => [
'customer.created' => 'Customer Created',
'customer.updated' => 'Customer Updated',
],
'Forms' => [
'form.submitted' => 'Form Submission',
'contact.created' => 'Contact Form Submission',
'inquiry.created' => 'Product Inquiry',
]
];
// Mock activity log data
$activityLog = collect([
[
'event' => 'webhook.triggered',
'webhook_name' => 'Order Notification',
'event_type' => 'order.created',
'status' => 'success',
'response_code' => 200,
'timestamp' => '2025-01-09 14:32:15',
'duration_ms' => 245,
],
[
'event' => 'webhook.triggered',
'webhook_name' => 'Order Notification',
'event_type' => 'order.updated',
'status' => 'success',
'response_code' => 200,
'timestamp' => '2025-01-09 12:18:42',
'duration_ms' => 198,
],
[
'event' => 'api.request',
'endpoint' => '/api/products',
'method' => 'GET',
'status' => 'success',
'response_code' => 200,
'timestamp' => '2025-01-09 10:05:33',
'duration_ms' => 87,
],
[
'event' => 'webhook.triggered',
'webhook_name' => 'Order Notification',
'event_type' => 'order.created',
'status' => 'failed',
'response_code' => 500,
'timestamp' => '2025-01-08 16:42:10',
'duration_ms' => 1023,
'error' => 'Connection timeout',
],
[
'event' => 'api.request',
'endpoint' => '/api/orders',
'method' => 'POST',
'status' => 'success',
'response_code' => 201,
'timestamp' => '2025-01-08 14:22:05',
'duration_ms' => 154,
],
]);
@endphp
<!-- Webhooks Section -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<span class="icon-[lucide--webhook] size-6 text-primary"></span>
<h2 class="text-lg font-semibold">Webhooks</h2>
</div>
<button onclick="addWebhookModal.showModal()" class="btn btn-primary btn-sm gap-2">
<span class="icon-[lucide--plus] size-4"></span>
Add Webhook
</button>
</div>
<!-- Webhooks Table -->
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Method</th>
<th>Url</th>
<th>Types</th>
<th>Last Triggered</th>
<th>Status</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($webhooks as $webhook)
<tr class="hover">
<td class="font-medium">{{ $webhook['name'] }}</td>
<td>
<span class="badge badge-ghost badge-sm font-mono">{{ strtoupper($webhook['method']) }}</span>
</td>
<td class="text-sm font-mono max-w-xs truncate" title="{{ $webhook['url'] }}">
{{ $webhook['url'] }}
</td>
<td>
<div class="flex gap-1 flex-wrap max-w-xs">
@foreach($webhook['types'] as $type)
<span class="badge badge-sm badge-outline">{{ $type }}</span>
@endforeach
</div>
</td>
<td class="text-sm text-base-content/60">
@if($webhook['last_triggered'])
{{ \Carbon\Carbon::parse($webhook['last_triggered'])->diffForHumans() }}
@else
<span class="text-base-content/40">Never</span>
@endif
</td>
<td>
@if($webhook['status'] === 'success')
<div class="tooltip" data-tip="Last delivery successful">
<span class="badge badge-success badge-sm gap-1">
<span class="icon-[lucide--check-circle] size-3"></span>
Success
</span>
</div>
@elseif($webhook['status'] === 'failed')
<div class="tooltip tooltip-error" data-tip="Last delivery failed">
<span class="badge badge-error badge-sm gap-1">
<span class="icon-[lucide--x-circle] size-3"></span>
Failed
</span>
</div>
@else
<span class="badge badge-ghost badge-sm">Pending</span>
@endif
</td>
<td>
<input type="checkbox" class="toggle toggle-success toggle-sm" {{ $webhook['enabled'] ? 'checked' : '' }} onchange="toggleWebhook({{ $webhook['id'] }})" />
</td>
<td>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[lucide--more-vertical] size-4"></span>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu menu-sm p-2 shadow-lg bg-base-100 rounded-box w-48 border border-base-300">
<li><a class="gap-2"><span class="icon-[lucide--edit] size-4"></span> Edit</a></li>
<li><a class="gap-2"><span class="icon-[lucide--send] size-4"></span> Test Webhook</a></li>
<li><a class="gap-2"><span class="icon-[lucide--history] size-4"></span> View History</a></li>
<li class="divider my-0"></li>
<li><a class="gap-2 text-error"><span class="icon-[lucide--trash-2] size-4"></span> Delete</a></li>
</ul>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="text-center py-12">
<div class="flex flex-col items-center gap-3 text-base-content/60">
<span class="icon-[lucide--webhook] size-12 opacity-40"></span>
<p class="font-medium">No webhooks configured</p>
<p class="text-sm">Add a webhook to receive real-time notifications about events in your account</p>
<button onclick="addWebhookModal.showModal()" class="btn btn-primary btn-sm gap-2 mt-2">
<span class="icon-[lucide--plus] size-4"></span>
Add Your First Webhook
</button>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
<!-- Company Webhook Key Section -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<div class="flex items-center gap-2 mb-4">
<span class="icon-[lucide--shield-check] size-6 text-primary"></span>
<h2 class="text-lg font-semibold">Company Webhook Key</h2>
</div>
<p class="text-sm text-base-content/60 mb-4">
This value should be used to verify incoming webhooks. All webhooks include a hashed signature in the request header that can be verified with this key.
</p>
<div class="form-control mb-4">
<div class="relative">
<input
type="text"
value="{{ $companyWebhookKey }}"
class="input input-bordered w-full font-mono text-sm pr-24"
readonly
id="webhookKeyInput"
/>
<button
onclick="copyToClipboard('webhookKeyInput')"
class="btn btn-ghost btn-sm absolute right-1 top-1/2 -translate-y-1/2 gap-2"
>
<span class="icon-[lucide--copy] size-4"></span>
Copy
</button>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Username</span>
</label>
<input
type="text"
value="{{ $username }}"
class="input input-bordered w-full max-w-md"
readonly
/>
</div>
<div class="mt-4">
<button class="btn btn-primary btn-sm gap-2">
<span class="icon-[lucide--refresh-cw] size-4"></span>
Generate Key
</button>
</div>
</div>
</div>
<!-- API Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- API Information Card -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<div class="flex items-center gap-2 mb-4">
<span class="icon-[lucide--code-2] size-6 text-primary"></span>
<h2 class="text-lg font-semibold">API</h2>
</div>
<p class="text-sm text-base-content/60 mb-4">
The following slug & identifier may be useful when using the API.
</p>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Company Slug</span>
</label>
<div class="relative">
<input
type="text"
value="{{ $companySlug }}"
class="input input-bordered w-full font-mono text-sm pr-24"
readonly
id="companySlugInput"
/>
<button
onclick="copyToClipboard('companySlugInput')"
class="btn btn-ghost btn-sm absolute right-1 top-1/2 -translate-y-1/2 gap-2"
>
<span class="icon-[lucide--copy] size-4"></span>
Copy
</button>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Company ID</span>
</label>
<div class="relative">
<input
type="text"
value="{{ $companyId }}"
class="input input-bordered w-full font-mono text-sm pr-24"
readonly
id="companyIdInput"
/>
<button
onclick="copyToClipboard('companyIdInput')"
class="btn btn-ghost btn-sm absolute right-1 top-1/2 -translate-y-1/2 gap-2"
>
<span class="icon-[lucide--copy] size-4"></span>
Copy
</button>
</div>
</div>
</div>
<div class="mt-6">
<a href="#" class="btn btn-outline btn-sm gap-2">
<span class="icon-[lucide--book-open] size-4"></span>
API Documentation
</a>
</div>
</div>
</div>
<!-- Your API Key Card -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<div class="flex items-center gap-2 mb-4">
<span class="icon-[lucide--key] size-6 text-primary"></span>
<h2 class="text-lg font-semibold">Your API Key</h2>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">Key</span>
</label>
<div class="relative">
<input
type="text"
value="{{ $apiKey }}"
class="input input-bordered w-full font-mono text-sm pr-24"
readonly
id="apiKeyInput"
/>
<button
onclick="copyToClipboard('apiKeyInput')"
class="btn btn-ghost btn-sm absolute right-1 top-1/2 -translate-y-1/2 gap-2"
>
<span class="icon-[lucide--copy] size-4"></span>
Copy
</button>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">Username</span>
</label>
<div class="relative">
<input
type="text"
value="{{ $username }}"
class="input input-bordered w-full font-mono text-sm pr-24"
readonly
id="apiUsernameInput"
/>
<button
onclick="copyToClipboard('apiUsernameInput')"
class="btn btn-ghost btn-sm absolute right-1 top-1/2 -translate-y-1/2 gap-2"
>
<span class="icon-[lucide--copy] size-4"></span>
Copy
</button>
</div>
</div>
<div class="alert alert-warning">
<span class="icon-[lucide--alert-triangle] size-4"></span>
<span class="text-sm">Keep your API key secure. Do not share it publicly or commit it to version control.</span>
</div>
<div class="mt-4">
<button class="btn btn-primary btn-sm gap-2">
<span class="icon-[lucide--refresh-cw] size-4"></span>
Generate Key
</button>
</div>
</div>
</div>
</div>
<!-- Add Webhook Modal -->
<dialog id="addWebhookModal" class="modal">
<div class="modal-box max-w-2xl">
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl">Add Webhook</h3>
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
</button>
</form>
</div>
<form method="POST" action="#">
@csrf
<div class="space-y-4">
<!-- Webhook Name -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Webhook Name</span>
</label>
<input type="text" name="name" placeholder="e.g., Order Created Hook" class="input input-bordered" required />
</div>
<!-- Webhook URL -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">URL</span>
</label>
<input type="url" name="url" placeholder="https://example.com/webhook" class="input input-bordered" required />
<label class="label">
<span class="label-text-alt">The endpoint that will receive webhook notifications</span>
</label>
</div>
<!-- HTTP Method -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Method</span>
</label>
<select name="method" class="select select-bordered" required>
<option value="POST" selected>POST</option>
<option value="GET">GET</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
</select>
</div>
<!-- Event Types -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Event Types</span>
<span class="label-text-alt">Select events that will trigger this webhook</span>
</label>
<div class="border border-base-300 rounded-lg p-4 space-y-4 max-h-96 overflow-y-auto">
@foreach($eventTypes as $category => $events)
<div>
<div class="flex items-center gap-2 mb-2">
<span class="icon-[lucide--folder] size-4 text-primary"></span>
<h4 class="font-semibold text-sm">{{ $category }}</h4>
</div>
<div class="space-y-1 ml-6">
@foreach($events as $value => $label)
<label class="label cursor-pointer justify-start gap-3 py-1">
<input type="checkbox" name="types[]" value="{{ $value }}" class="checkbox checkbox-sm checkbox-primary" />
<div class="flex flex-col">
<span class="label-text">{{ $label }}</span>
<span class="label-text-alt text-xs font-mono opacity-60">{{ $value }}</span>
</div>
</label>
@endforeach
</div>
</div>
@endforeach
</div>
<label class="label">
<span class="label-text-alt">You can select multiple event types for one webhook</span>
</label>
</div>
<!-- Enabled Toggle -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="enabled" value="1" class="toggle toggle-primary" checked />
<span class="label-text font-medium">Enable webhook</span>
</label>
</div>
</div>
<!-- Modal Actions -->
<div class="modal-action">
<form method="dialog">
<button type="button" class="btn btn-ghost">Cancel</button>
</form>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Webhook
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Activity Log Section -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<span class="icon-[lucide--activity] size-6 text-primary"></span>
<h2 class="text-lg font-semibold">Activity Log</h2>
</div>
<button class="btn btn-ghost btn-sm gap-2">
<span class="icon-[lucide--download] size-4"></span>
Export
</button>
</div>
<!-- Activity Table -->
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Event</th>
<th>Details</th>
<th>Status</th>
<th>Response</th>
<th>Duration</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
@forelse($activityLog as $log)
<tr class="hover">
<!-- Event Type -->
<td>
@if($log['event'] === 'webhook.triggered')
<div class="flex items-center gap-2">
<span class="icon-[lucide--webhook] size-4 text-primary"></span>
<span class="font-medium">Webhook</span>
</div>
@else
<div class="flex items-center gap-2">
<span class="icon-[lucide--globe] size-4 text-info"></span>
<span class="font-medium">API Request</span>
</div>
@endif
</td>
<!-- Details -->
<td>
@if($log['event'] === 'webhook.triggered')
<div class="flex flex-col">
<span class="text-sm font-medium">{{ $log['webhook_name'] }}</span>
<span class="text-xs text-base-content/60">{{ $log['event_type'] }}</span>
</div>
@else
<div class="flex flex-col">
<span class="badge badge-ghost badge-sm font-mono">{{ strtoupper($log['method']) }}</span>
<span class="text-xs font-mono mt-1">{{ $log['endpoint'] }}</span>
</div>
@endif
</td>
<!-- Status -->
<td>
@if($log['status'] === 'success')
<span class="badge badge-success badge-sm gap-1">
<span class="icon-[lucide--check-circle] size-3"></span>
Success
</span>
@else
<div class="tooltip" data-tip="{{ $log['error'] ?? 'Request failed' }}">
<span class="badge badge-error badge-sm gap-1">
<span class="icon-[lucide--x-circle] size-3"></span>
Failed
</span>
</div>
@endif
</td>
<!-- Response Code -->
<td>
<span class="font-mono text-sm {{ $log['response_code'] >= 200 && $log['response_code'] < 300 ? 'text-success' : ($log['response_code'] >= 400 ? 'text-error' : 'text-warning') }}">
{{ $log['response_code'] }}
</span>
</td>
<!-- Duration -->
<td class="text-sm text-base-content/60">
{{ $log['duration_ms'] }}ms
</td>
<!-- Timestamp -->
<td class="text-sm text-base-content/60">
{{ \Carbon\Carbon::parse($log['timestamp'])->format('M j, Y g:i A') }}
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-3 text-base-content/60">
<span class="icon-[lucide--activity] size-12 opacity-40"></span>
<p class="font-medium">No activity yet</p>
<p class="text-sm">Webhook and API activity will appear here</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
function copyToClipboard(inputId) {
const input = document.getElementById(inputId);
input.select();
input.setSelectionRange(0, 99999);
navigator.clipboard.writeText(input.value).then(() => {
const button = event.target.closest('button');
const originalHTML = button.innerHTML;
button.innerHTML = '<span class="icon-[lucide--check] size-4"></span> Copied!';
button.classList.add('btn-success');
setTimeout(() => {
button.innerHTML = originalHTML;
button.classList.remove('btn-success');
}, 2000);
});
}
function toggleWebhook(webhookId) {
// TODO: Make AJAX call to toggle webhook enabled/disabled status
console.log('Toggling webhook ' + webhookId);
// Example implementation (uncomment when backend is ready):
/*
fetch(`/seller/{{ $business->slug }}/settings/webhooks/${webhookId}/toggle`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success notification
console.log('Webhook toggled successfully');
}
})
.catch(error => {
console.error('Error toggling webhook:', error);
// Revert toggle on error
event.target.checked = !event.target.checked;
});
*/
}
</script>
@endpush

View File

@@ -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>

View File

@@ -176,7 +176,22 @@ Route::prefix('b')->name('buyer.')->middleware('buyer')->group(function () {
Route::post('/invoices/{invoice}/reject', [\App\Http\Controllers\Buyer\InvoiceController::class, 'reject'])->name('business.invoices.reject');
Route::post('/invoices/{invoice}/modify', [\App\Http\Controllers\Buyer\InvoiceController::class, 'modify'])->name('business.invoices.modify');
// Business management (locations, contacts, users) (business-scoped)
// ========================================
// MODULE ROUTES (Isolated Features)
// ========================================
// Application has 3 isolated route areas:
// - /admin (Superadmin platform management - Filament)
// - /b/{business}/* (Buyer/Dispensary modules - DaisyUI, defined below)
// - /s/{business}/* (Seller/Brand modules - DaisyUI)
//
// Buyer modules are isolated for:
// - Parallel development without route collisions
// - Permission-based access control
// - Consistent architecture with seller modules
// Settings Module (Required)
// Always enabled, controlled by role-based permissions
// Features: Business profile, locations, contacts, team management
Route::prefix('settings')->name('business.')->group(function () {
Route::get('/profile', [\App\Http\Controllers\Business\ProfileController::class, 'show'])->name('profile');
Route::resource('locations', \App\Http\Controllers\Business\LocationController::class);

View File

@@ -35,6 +35,11 @@ Route::bind('invoice', function (string $value) {
->firstOrFail();
});
// Custom route model binding for users by UUID
Route::bind('user', function (string $value) {
return \App\Models\User::where('uuid', $value)->firstOrFail();
});
// Seller-specific routes under /s/ prefix (moved from /b/)
Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
// Root redirect to dashboard
@@ -223,19 +228,100 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::delete('/{component}', [\App\Http\Controllers\Seller\ComponentController::class, 'destroy'])->name('destroy');
});
// Settings Management (business-scoped)
// ========================================
// MODULE ROUTES (Isolated Features)
// ========================================
// Application has 3 isolated route areas:
// - /admin (Superadmin platform management - Filament)
// - /b/{business}/* (Buyer/Dispensary modules - DaisyUI)
// - /s/{business}/* (Seller/Brand modules - DaisyUI, defined below)
//
// Each seller module below is isolated for:
// - Parallel development without route collisions
// - Permission-based access control
// - Feature enablement (via business flags or roles)
// Manufacturing Module (Optional)
// Flag: has_manufacturing
// Features: Production tracking, batch management, conversions
Route::prefix('manufacturing')->name('manufacturing.')->group(function () {
// Manufacturing routes will be added here
// Examples: batches, wash-reports, conversions, work-orders, boms
});
// Compliance Module (Optional)
// Flag: has_compliance
// Features: Regulatory tracking, METRC integration, lab results
Route::prefix('compliance')->name('compliance.')->group(function () {
// Compliance routes will be added here
// Examples: metrc, incoming-materials, lab-results, quarantine
});
// Marketing Module (Optional)
// Flag: has_marketing
// Features: Social media management, campaigns, email marketing
Route::prefix('marketing')->name('marketing.')->group(function () {
// Marketing routes will be added here
// Examples: campaigns, email-marketing, social-media, promotions, customer-segments
});
// Analytics Module (Optional)
// Flag: has_analytics
// Features: Business intelligence, cross-module reporting, executive dashboards
Route::prefix('analytics')->name('analytics.')->group(function () {
// Analytics routes will be added here
// Examples: overview, sales-analytics, product-analytics, customer-analytics, manufacturing-analytics, marketing-analytics
});
// View Switcher (business-scoped but outside settings group)
Route::post('/view/switch', [\App\Http\Controllers\Seller\SettingsController::class, 'switchView'])->name('view.switch');
// Settings Module
// Always enabled, controlled by role-based permissions
// Features: Business configuration, user management, billing
Route::prefix('settings')->name('settings.')->group(function () {
Route::get('/company-information', [\App\Http\Controllers\Seller\SettingsController::class, 'companyInformation'])->name('company-information');
Route::put('/company-information', [\App\Http\Controllers\Seller\SettingsController::class, 'updateCompanyInformation'])->name('company-information.update');
Route::get('/users', [\App\Http\Controllers\Seller\SettingsController::class, 'users'])->name('users');
Route::get('/orders', [\App\Http\Controllers\Seller\SettingsController::class, 'orders'])->name('orders');
Route::get('/sales-config', [\App\Http\Controllers\Seller\SettingsController::class, 'salesConfig'])->name('sales-config');
Route::put('/sales-config', [\App\Http\Controllers\Seller\SettingsController::class, 'updateSalesConfig'])->name('sales-config.update');
// Legacy routes that redirect to sales-config (orders and invoices were consolidated)
Route::get('/orders', function(\App\Models\Business $business) {
return redirect()->route('seller.business.settings.sales-config', $business->slug);
})->name('orders');
Route::get('/invoices', [\App\Http\Controllers\Seller\SettingsController::class, 'invoices'])->name('invoices');
Route::get('/brand-kit', [\App\Http\Controllers\Seller\SettingsController::class, 'brandKit'])->name('brand-kit');
Route::get('/brands', [\App\Http\Controllers\Seller\SettingsController::class, 'brands'])->name('brands');
Route::get('/payments', [\App\Http\Controllers\Seller\SettingsController::class, 'payments'])->name('payments');
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::put('/notifications', [\App\Http\Controllers\Seller\SettingsController::class, 'updateNotifications'])->name('notifications.update');
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');
Route::get('/profile', [\App\Http\Controllers\Seller\SettingsController::class, 'profile'])->name('profile');
Route::put('/profile', [\App\Http\Controllers\Seller\SettingsController::class, 'updateProfile'])->name('profile.update');
Route::put('/profile/password', [\App\Http\Controllers\Seller\SettingsController::class, 'updatePassword'])->name('password.update');
Route::get('/users/edit/{user:uuid}', [\App\Http\Controllers\Seller\SettingsController::class, 'editUser'])->name('users.edit');
Route::post('/users/invite', [\App\Http\Controllers\Seller\SettingsController::class, 'inviteUser'])->name('users.invite');
Route::patch('/users/{user:uuid}', [\App\Http\Controllers\Seller\SettingsController::class, 'updateUser'])->name('users.update');
Route::delete('/users/{user:uuid}', [\App\Http\Controllers\Seller\SettingsController::class, 'removeUser'])->name('users.remove');
// 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/{type}', [\App\Http\Controllers\Seller\CategoryController::class, 'create'])->name('create')->where('type', 'product|component');
Route::post('/{type}', [\App\Http\Controllers\Seller\CategoryController::class, 'store'])->name('store')->where('type', 'product|component');
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');
});
});
});
});

View File

@@ -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;
@@ -199,9 +198,3 @@ Route::prefix('api')->group(function () {
->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');
});

View 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',
]);
}
}

View 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();
}
}