Compare commits
39 commits
feature/2-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b31f3d315 | |||
| 592a402d23 | |||
| fc6fd87c4b | |||
| bcbc1ce8e7 | |||
| 71668ea5bd | |||
| b1cf8b5f22 | |||
| 0baa87e373 | |||
| 7412316746 | |||
| d57af05974 | |||
| 1b7c04e29f | |||
| 7bb1bb4161 | |||
| 0de6c30dab | |||
| 6723c9813b | |||
| 01648359b5 | |||
| efa3f62146 | |||
| 89184b79a5 | |||
| 98230a5ef2 | |||
| 197a74ee9b | |||
| faed07395e | |||
| 2ed9dfbdaa | |||
| b93e6cb832 | |||
| 09236f6f10 | |||
| 1174f2fbda | |||
| 53840d5ced | |||
| 8336df4551 | |||
| f226295d72 | |||
| 28da206043 | |||
| 77817bca14 | |||
| dc00300f44 | |||
| ecfadbc2ad | |||
| 01ee82cbac | |||
| 9e22c23208 | |||
| 3c32d49977 | |||
| 94abe140e1 | |||
| 6e5fb9cb36 | |||
| 71212ef9da | |||
| 630a8cc73f | |||
| 5a26120141 | |||
| 71ed93fda0 |
558 changed files with 12078 additions and 6546 deletions
62
.dockerignore
Normal file
62
.dockerignore
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
LICENSE
|
||||
docs/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Dependencies (will be installed fresh)
|
||||
vendor/
|
||||
node_modules/
|
||||
|
||||
# Storage (will be mounted as volumes)
|
||||
storage/app/*
|
||||
storage/framework/cache/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
bootstrap/cache/*
|
||||
|
||||
# Testing
|
||||
phpunit.xml
|
||||
.phpunit.result.cache
|
||||
tests/
|
||||
coverage/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Frontend old
|
||||
frontend-old/
|
||||
|
||||
# Build artifacts
|
||||
public/build/
|
||||
public/hot
|
||||
public/mix-manifest.json
|
||||
|
||||
# Development
|
||||
.php_cs.cache
|
||||
.php-cs-fixer.cache
|
||||
phpstan.neon
|
||||
.editorconfig
|
||||
24
.env.dusk.local
Normal file
24
.env.dusk.local
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
APP_NAME=DishPlanner
|
||||
APP_ENV=testing
|
||||
APP_KEY=base64:KSKZNT+cJuaBRBv4Y2HQqav6hzREKoLkNIKN8yszU1Q=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://dishplanner_app:8000
|
||||
|
||||
LOG_CHANNEL=single
|
||||
|
||||
# Test database
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=dishplanner_test
|
||||
DB_USERNAME=dishplanner
|
||||
DB_PASSWORD=dishplanner
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
CACHE_DRIVER=array
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=sync
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
MAIL_MAILER=array
|
||||
66
.env.example
Normal file
66
.env.example
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Application Settings
|
||||
APP_NAME=DishPlanner
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Database Connection (Docker)
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db # Use 'db' for Docker, 'localhost' for local
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=dishplanner
|
||||
DB_USERNAME=dishplanner
|
||||
DB_PASSWORD=dishplanner
|
||||
DB_ROOT_PASSWORD=root # For Docker MariaDB root user
|
||||
|
||||
# Session & Cache
|
||||
SESSION_DRIVER=file # Use 'database' for production
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
CACHE_STORE=file
|
||||
CACHE_PREFIX=dishplanner
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=sync # Use 'database' for production
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Redis (Optional - uncomment if using Redis)
|
||||
# REDIS_CLIENT=phpredis
|
||||
# REDIS_HOST=redis # Use 'redis' for Docker
|
||||
# REDIS_PASSWORD=null
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# Mail Settings
|
||||
MAIL_MAILER=log # Use 'smtp' for production
|
||||
MAIL_HOST=mailhog # Use 'mailhog' for Docker dev
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="noreply@dishplanner.local"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
0
backend/.gitattributes → .gitattributes
vendored
0
backend/.gitattributes → .gitattributes
vendored
25
.gitignore
vendored
25
.gitignore
vendored
|
|
@ -1 +1,26 @@
|
|||
/composer.lock
|
||||
/.phpunit.cache
|
||||
/coverage
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/auth.json
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
/.vite
|
||||
|
|
|
|||
8
.vite/deps/_metadata.json
Normal file
8
.vite/deps/_metadata.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"hash": "9e76e24f",
|
||||
"configHash": "16a1459d",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "8e8bb46e",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
.vite/deps/package.json
Normal file
3
.vite/deps/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
123
Dockerfile
Normal file
123
Dockerfile
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Production Dockerfile with FrankenPHP
|
||||
FROM dunglas/frankenphp:latest-php8.3-alpine
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
nodejs \
|
||||
npm \
|
||||
git \
|
||||
mysql-client
|
||||
|
||||
# Install PHP extensions
|
||||
RUN install-php-extensions \
|
||||
pdo_mysql \
|
||||
opcache \
|
||||
zip \
|
||||
gd \
|
||||
intl \
|
||||
bcmath
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set fixed production environment variables
|
||||
ENV APP_ENV=production \
|
||||
APP_DEBUG=false \
|
||||
DB_CONNECTION=mysql \
|
||||
DB_HOST=db \
|
||||
DB_PORT=3306 \
|
||||
SESSION_DRIVER=database \
|
||||
CACHE_DRIVER=file \
|
||||
QUEUE_CONNECTION=database \
|
||||
LOG_CHANNEL=stack \
|
||||
LOG_LEVEL=error \
|
||||
MAIL_MAILER=smtp \
|
||||
MAIL_ENCRYPTION=tls
|
||||
|
||||
# Copy application code first
|
||||
COPY . .
|
||||
|
||||
# Install PHP dependencies (production only)
|
||||
RUN composer install --no-dev --no-interaction --optimize-autoloader
|
||||
|
||||
# Install ALL Node dependencies (including dev for building)
|
||||
RUN npm ci
|
||||
|
||||
# Build frontend assets
|
||||
RUN npm run build
|
||||
|
||||
# Remove node_modules after build to save space
|
||||
RUN rm -rf node_modules
|
||||
|
||||
# Laravel optimizations
|
||||
RUN php artisan config:cache \
|
||||
&& php artisan route:cache \
|
||||
&& composer dump-autoload --optimize
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache
|
||||
|
||||
# Configure Caddy
|
||||
RUN cat > /etc/caddy/Caddyfile <<EOF
|
||||
{
|
||||
frankenphp
|
||||
order php_server before file_server
|
||||
}
|
||||
|
||||
:8000 {
|
||||
root * /app/public
|
||||
|
||||
php_server {
|
||||
index index.php
|
||||
}
|
||||
|
||||
encode gzip
|
||||
|
||||
file_server
|
||||
|
||||
header {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/up || exit 1
|
||||
|
||||
# Create startup script for production
|
||||
RUN cat > /start-prod.sh <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Wait for database to be ready
|
||||
echo "Waiting for database..."
|
||||
for i in $(seq 1 30); do
|
||||
if mysqladmin ping -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" --silent 2>/dev/null; then
|
||||
echo "Database is ready!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for database... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Run migrations
|
||||
echo "Running migrations..."
|
||||
php artisan migrate --force || echo "Migrations failed or already up-to-date"
|
||||
|
||||
# Start FrankenPHP
|
||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||
EOF
|
||||
|
||||
RUN chmod +x /start-prod.sh
|
||||
|
||||
# Start with our script
|
||||
CMD ["/start-prod.sh"]
|
||||
128
Dockerfile.dev
Normal file
128
Dockerfile.dev
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# Development Dockerfile with FrankenPHP
|
||||
FROM dunglas/frankenphp:latest-php8.3-alpine
|
||||
|
||||
# Install system dependencies + development tools
|
||||
RUN apk add --no-cache \
|
||||
nodejs \
|
||||
npm \
|
||||
git \
|
||||
mysql-client \
|
||||
vim \
|
||||
bash \
|
||||
nano
|
||||
|
||||
# Install PHP extensions including xdebug for development
|
||||
RUN install-php-extensions \
|
||||
pdo_mysql \
|
||||
opcache \
|
||||
zip \
|
||||
gd \
|
||||
intl \
|
||||
bcmath \
|
||||
xdebug
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Configure PHP for development
|
||||
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
|
||||
|
||||
# Configure Xdebug (disabled by default to reduce noise)
|
||||
RUN echo "xdebug.mode=off" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||
&& echo ";xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||
&& echo ";xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||
&& echo ";xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
|
||||
# Configure Caddy for development (simpler, no worker mode)
|
||||
RUN cat > /etc/caddy/Caddyfile <<EOF
|
||||
{
|
||||
frankenphp
|
||||
order php_server before file_server
|
||||
}
|
||||
|
||||
:8000 {
|
||||
root * /app/public
|
||||
|
||||
php_server {
|
||||
index index.php
|
||||
}
|
||||
|
||||
encode gzip
|
||||
|
||||
file_server
|
||||
|
||||
# Less strict headers for development
|
||||
header {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Install Node development dependencies globally
|
||||
RUN npm install -g nodemon
|
||||
|
||||
# Create startup script for development (runs as host user)
|
||||
RUN cat > /start.sh <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "Creating .env file from .env.example..."
|
||||
cp .env.example .env
|
||||
fi
|
||||
|
||||
# Install dependencies if volumes are empty
|
||||
if [ ! -f "vendor/autoload.php" ]; then
|
||||
echo "Installing composer dependencies..."
|
||||
composer install
|
||||
fi
|
||||
|
||||
# Handle node_modules with care - clean install if having issues
|
||||
if [ ! -f "node_modules/.bin/vite" ]; then
|
||||
echo "Installing npm dependencies..."
|
||||
# Clean any remnants first
|
||||
rm -rf node_modules/.* 2>/dev/null || true
|
||||
rm -rf /app/.npm 2>/dev/null || true
|
||||
# Fresh install with cache in tmp to avoid permission issues
|
||||
npm install --cache /tmp/.npm
|
||||
else
|
||||
echo "Node modules already installed, skipping npm install"
|
||||
fi
|
||||
|
||||
# Clear Laravel caches
|
||||
php artisan config:clear || true
|
||||
php artisan cache:clear || true
|
||||
|
||||
# Wait for database and run migrations
|
||||
echo "Waiting for database..."
|
||||
sleep 5
|
||||
php artisan migrate --force || echo "Migration failed or not needed"
|
||||
|
||||
# Run development seeder (only in dev environment)
|
||||
echo "Running development seeder..."
|
||||
php artisan db:seed --class=DevelopmentSeeder --force || echo "Seeding skipped or already done"
|
||||
|
||||
# Generate app key if not set
|
||||
if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then
|
||||
echo "Generating application key..."
|
||||
php artisan key:generate
|
||||
fi
|
||||
|
||||
# Start Vite dev server in background
|
||||
npm run dev &
|
||||
|
||||
# Start FrankenPHP
|
||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||
EOF
|
||||
|
||||
RUN chmod +x /start.sh
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 8000 5173
|
||||
|
||||
# Use the startup script
|
||||
CMD ["/start.sh"]
|
||||
123
Makefile
Normal file
123
Makefile
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Dish Planner - Docker Commands
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help message
|
||||
@echo "Dish Planner - Docker Management"
|
||||
@echo ""
|
||||
@echo "Usage: make [command]"
|
||||
@echo ""
|
||||
@echo "Available commands:"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}'
|
||||
|
||||
# Development Commands
|
||||
.PHONY: dev
|
||||
dev: ## Start development environment
|
||||
docker compose up -d
|
||||
@echo "Development server running at http://localhost:8000"
|
||||
@echo "Mailhog available at http://localhost:8025"
|
||||
|
||||
.PHONY: dev-build
|
||||
dev-build: ## Build and start development environment
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
.PHONY: dev-stop
|
||||
dev-stop: ## Stop development environment
|
||||
docker compose down
|
||||
|
||||
.PHONY: dev-clean
|
||||
dev-clean: ## Stop and remove volumes (CAUTION: removes database)
|
||||
docker compose down -v
|
||||
|
||||
.PHONY: logs
|
||||
logs: ## Show application logs
|
||||
docker compose logs -f app
|
||||
|
||||
.PHONY: logs-db
|
||||
logs-db: ## Show database logs
|
||||
docker compose logs -f db
|
||||
|
||||
# Production Commands
|
||||
.PHONY: prod-build
|
||||
prod-build: ## Build production image for Codeberg
|
||||
./bin/build-push.sh
|
||||
|
||||
.PHONY: prod-build-tag
|
||||
prod-build-tag: ## Build with specific tag (usage: make prod-build-tag TAG=v1.0.0)
|
||||
./bin/build-push.sh $(TAG)
|
||||
|
||||
.PHONY: prod-login
|
||||
prod-login: ## Login to Codeberg registry
|
||||
podman login codeberg.org
|
||||
|
||||
# Laravel Commands
|
||||
.PHONY: artisan
|
||||
artisan: ## Run artisan command (usage: make artisan cmd="migrate")
|
||||
docker compose exec app php artisan $(cmd)
|
||||
|
||||
.PHONY: composer
|
||||
composer: ## Run composer command (usage: make composer cmd="require package")
|
||||
docker compose exec app composer $(cmd)
|
||||
|
||||
.PHONY: npm
|
||||
npm: ## Run npm command (usage: make npm cmd="install package")
|
||||
docker compose exec app npm $(cmd)
|
||||
|
||||
.PHONY: migrate
|
||||
migrate: ## Run database migrations
|
||||
docker compose exec app php artisan migrate
|
||||
|
||||
.PHONY: seed
|
||||
seed: ## Seed the database
|
||||
docker compose exec app php artisan db:seed
|
||||
|
||||
.PHONY: fresh
|
||||
fresh: ## Fresh migrate and seed
|
||||
docker compose exec app php artisan migrate:fresh --seed
|
||||
|
||||
.PHONY: tinker
|
||||
tinker: ## Start Laravel tinker
|
||||
docker compose exec app php artisan tinker
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run tests
|
||||
docker compose exec app php artisan test
|
||||
|
||||
# Utility Commands
|
||||
.PHONY: shell
|
||||
shell: ## Enter app container shell
|
||||
docker compose exec app sh
|
||||
|
||||
.PHONY: db-shell
|
||||
db-shell: ## Enter database shell
|
||||
docker compose exec db mariadb -u dishplanner -pdishplanner dishplanner
|
||||
|
||||
.PHONY: clear
|
||||
clear: ## Clear all Laravel caches
|
||||
docker compose exec app php artisan cache:clear
|
||||
docker compose exec app php artisan config:clear
|
||||
docker compose exec app php artisan route:clear
|
||||
docker compose exec app php artisan view:clear
|
||||
|
||||
.PHONY: optimize
|
||||
optimize: ## Optimize Laravel for production
|
||||
docker compose exec app php artisan config:cache
|
||||
docker compose exec app php artisan route:cache
|
||||
docker compose exec app php artisan view:cache
|
||||
docker compose exec app php artisan livewire:discover
|
||||
|
||||
# Installation
|
||||
.PHONY: install
|
||||
install: ## First time setup
|
||||
@echo "Setting up Dish Planner..."
|
||||
@cp -n .env.example .env || true
|
||||
@echo "Generating application key..."
|
||||
@docker compose build
|
||||
@docker compose up -d
|
||||
@sleep 5
|
||||
@docker compose exec app php artisan key:generate
|
||||
@docker compose exec app php artisan migrate
|
||||
@echo ""
|
||||
@echo "✅ Installation complete!"
|
||||
@echo "Access the app at: http://localhost:8000"
|
||||
@echo "Create your first planner and user to get started."
|
||||
295
README.md
Normal file
295
README.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# 🍽️ Dish Planner
|
||||
|
||||
A Laravel-based meal planning application that helps households organize and schedule their dishes among multiple users. Built with Laravel, Livewire, and FrankenPHP for a modern, single-container deployment.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Multi-user dish management** - Assign dishes to specific household members
|
||||
- **Smart scheduling** - Automatic schedule generation with recurrence patterns
|
||||
- **Calendar view** - Visual 31-day schedule overview
|
||||
- **Real-time updates** - Livewire-powered reactive interface
|
||||
- **Dark theme UI** - Modern interface with purple/pink accents
|
||||
- **Single container deployment** - Simplified hosting with FrankenPHP
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- Make (optional, for convenience commands)
|
||||
|
||||
### First Time Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/dish-planner.git
|
||||
cd dish-planner
|
||||
|
||||
# Quick install with Make
|
||||
make install
|
||||
|
||||
# Or manually:
|
||||
cp .env.example .env
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose exec app php artisan key:generate
|
||||
docker compose exec app php artisan migrate
|
||||
```
|
||||
|
||||
The application will be available at **http://localhost:8000**
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Starting the Development Environment
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
make dev
|
||||
|
||||
# Or with Docker Compose directly
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Available services:**
|
||||
- **App**: http://localhost:8000 (Laravel + FrankenPHP)
|
||||
- **Vite**: http://localhost:5173 (Asset hot-reload)
|
||||
- **Mailhog**: http://localhost:8025 (Email testing)
|
||||
- **Database**: localhost:3306 (MariaDB)
|
||||
|
||||
### Common Development Commands
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
make logs # App logs
|
||||
make logs-db # Database logs
|
||||
|
||||
# Laravel commands
|
||||
make artisan cmd="migrate" # Run artisan commands
|
||||
make tinker # Start Laravel tinker
|
||||
make test # Run tests
|
||||
|
||||
# Database
|
||||
make migrate # Run migrations
|
||||
make seed # Seed database
|
||||
make fresh # Fresh migrate with seeds
|
||||
|
||||
# Testing
|
||||
make test # Run tests
|
||||
composer test:coverage-html # Run tests with coverage report (generates coverage/index.html)
|
||||
|
||||
# Utilities
|
||||
make shell # Enter app container
|
||||
make db-shell # Enter database shell
|
||||
make clear # Clear all caches
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
dish-planner/
|
||||
├── app/
|
||||
│ ├── Livewire/ # Livewire components
|
||||
│ │ ├── Auth/ # Authentication
|
||||
│ │ ├── Dishes/ # Dish management
|
||||
│ │ ├── Schedule/ # Schedule calendar
|
||||
│ │ └── Users/ # User management
|
||||
│ └── Models/ # Eloquent models
|
||||
├── resources/
|
||||
│ └── views/
|
||||
│ └── livewire/ # Livewire component views
|
||||
├── docker-compose.yml # Development environment
|
||||
├── docker-compose.prod.yml # Production environment
|
||||
├── Dockerfile # Production image
|
||||
├── Dockerfile.dev # Development image
|
||||
└── Makefile # Convenience commands
|
||||
```
|
||||
|
||||
## 🚢 Production Deployment
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
# Build production image
|
||||
make prod-build
|
||||
|
||||
# Start production environment
|
||||
make prod
|
||||
|
||||
# Or with Docker Compose
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Production Environment Variables
|
||||
|
||||
Required environment variables for production:
|
||||
|
||||
```env
|
||||
# Required - Generate APP_KEY (see instructions below)
|
||||
APP_KEY=base64:your-generated-key-here
|
||||
APP_URL=https://your-domain.com
|
||||
|
||||
# Database Configuration
|
||||
DB_DATABASE=dishplanner
|
||||
DB_USERNAME=dishplanner
|
||||
DB_PASSWORD=strong-password-here
|
||||
DB_ROOT_PASSWORD=strong-root-password
|
||||
|
||||
# Optional Email Configuration
|
||||
MAIL_HOST=your-smtp-host
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=your-username
|
||||
MAIL_PASSWORD=your-password
|
||||
MAIL_FROM_ADDRESS=noreply@your-domain.com
|
||||
```
|
||||
|
||||
#### Generating APP_KEY
|
||||
|
||||
The APP_KEY is critical for encryption and must be kept consistent across deployments. Generate one using any of these methods:
|
||||
|
||||
**Option 1: Using OpenSSL (Linux/Mac/Windows with Git Bash)**
|
||||
```bash
|
||||
echo "base64:$(openssl rand -base64 32)"
|
||||
```
|
||||
|
||||
**Option 2: Using Node.js (Cross-platform)**
|
||||
```bash
|
||||
node -e "console.log('base64:' + require('crypto').randomBytes(32).toString('base64'))"
|
||||
```
|
||||
|
||||
**Option 3: Using Python (Cross-platform)**
|
||||
```bash
|
||||
python -c "import base64, os; print('base64:' + base64.b64encode(os.urandom(32)).decode())"
|
||||
```
|
||||
|
||||
**Option 4: Online Generator**
|
||||
Generate a random 32-character string at https://randomkeygen.com/ and prepend with `base64:`
|
||||
|
||||
⚠️ **Important**: Save this key securely! If lost, you won't be able to decrypt existing data.
|
||||
|
||||
### Deployment with DockGE
|
||||
|
||||
The production setup is optimized for DockGE deployment with just 2 containers:
|
||||
|
||||
1. **app** - Laravel application with FrankenPHP
|
||||
2. **db** - MariaDB database
|
||||
|
||||
Simply import the `docker-compose.prod.yml` into DockGE and configure your environment variables.
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
- **Backend**: Laravel 12 with Livewire 3
|
||||
- **Web Server**: FrankenPHP (PHP 8.3 + Caddy)
|
||||
- **Database**: MariaDB 11
|
||||
- **Frontend**: Blade + Livewire + Alpine.js
|
||||
- **Styling**: TailwindCSS with custom dark theme
|
||||
- **Assets**: Vite for bundling
|
||||
|
||||
## 📦 Docker Architecture
|
||||
|
||||
### Development (`docker-compose.yml`)
|
||||
- **Hot reload** - Volume mounts for live code editing
|
||||
- **Debug tools** - Xdebug configured for debugging
|
||||
- **Email testing** - Mailhog for capturing emails
|
||||
- **Asset watching** - Vite dev server for instant updates
|
||||
|
||||
### Production (`docker-compose.prod.yml`)
|
||||
- **Optimized** - Multi-stage builds with caching
|
||||
- **Secure** - No debug tools, proper permissions
|
||||
- **Health checks** - Automatic container monitoring
|
||||
- **Single container** - FrankenPHP serves everything
|
||||
|
||||
## 🔨 Make Commands Reference
|
||||
|
||||
```bash
|
||||
# Development
|
||||
make dev # Start development environment
|
||||
make dev-build # Build and start development
|
||||
make dev-stop # Stop development environment
|
||||
make dev-clean # Stop and remove volumes (CAUTION)
|
||||
|
||||
# Production
|
||||
make prod # Start production environment
|
||||
make prod-build # Build production image
|
||||
make prod-stop # Stop production environment
|
||||
make prod-logs # Show production logs
|
||||
|
||||
# Laravel
|
||||
make artisan cmd="..." # Run artisan command
|
||||
make composer cmd="..." # Run composer command
|
||||
make npm cmd="..." # Run npm command
|
||||
make migrate # Run migrations
|
||||
make seed # Seed database
|
||||
make fresh # Fresh migrate and seed
|
||||
make tinker # Start tinker session
|
||||
make test # Run tests
|
||||
|
||||
# Utilities
|
||||
make shell # Enter app container
|
||||
make db-shell # Enter database shell
|
||||
make logs # Show app logs
|
||||
make logs-db # Show database logs
|
||||
make clear # Clear all caches
|
||||
make optimize # Optimize for production
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs app
|
||||
|
||||
# Rebuild containers
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Database connection issues
|
||||
```bash
|
||||
# Verify database is running
|
||||
docker compose ps
|
||||
|
||||
# Check database logs
|
||||
docker compose logs db
|
||||
|
||||
# Try manual connection
|
||||
make db-shell
|
||||
```
|
||||
|
||||
### Permission issues
|
||||
```bash
|
||||
# Fix storage permissions
|
||||
docker compose exec app chmod -R 777 storage bootstrap/cache
|
||||
```
|
||||
|
||||
### Clear all caches
|
||||
```bash
|
||||
make clear
|
||||
# Or manually
|
||||
docker compose exec app php artisan cache:clear
|
||||
docker compose exec app php artisan config:clear
|
||||
docker compose exec app php artisan route:clear
|
||||
docker compose exec app php artisan view:clear
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is open-source software licensed under the [MIT license](LICENSE.md).
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues and questions, please use the [GitHub Issues](https://github.com/yourusername/dish-planner/issues) page.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using Laravel and Livewire
|
||||
84
app/Actions/User/CreateUserAction.php
Normal file
84
app/Actions/User/CreateUserAction.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class CreateUserAction
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function execute(array $data): User
|
||||
{
|
||||
try {
|
||||
// Validate required fields first
|
||||
if (!isset($data['name']) || empty($data['name'])) {
|
||||
throw new InvalidArgumentException('Name is required');
|
||||
}
|
||||
|
||||
if (!isset($data['planner_id']) || empty($data['planner_id'])) {
|
||||
throw new InvalidArgumentException('Planner ID is required');
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
Log::info('CreateUserAction: Starting user creation', [
|
||||
'name' => $data['name'],
|
||||
'planner_id' => $data['planner_id'],
|
||||
]);
|
||||
|
||||
// Create the user
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'planner_id' => $data['planner_id'],
|
||||
]);
|
||||
|
||||
if (!$user) {
|
||||
throw new Exception('User creation returned null');
|
||||
}
|
||||
|
||||
Log::info('CreateUserAction: User creation result', [
|
||||
'user_id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'planner_id' => $user->planner_id,
|
||||
]);
|
||||
|
||||
// Verify the user was actually created
|
||||
$createdUser = User::find($user->id);
|
||||
if (!$createdUser) {
|
||||
throw new Exception('User creation did not persist to database');
|
||||
}
|
||||
|
||||
if ($createdUser->name !== $data['name']) {
|
||||
throw new Exception('User creation data mismatch');
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
Log::info('CreateUserAction: User successfully created', [
|
||||
'user_id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'planner_id' => $user->planner_id,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error('CreateUserAction: User creation failed', [
|
||||
'name' => $data['name'] ?? 'N/A',
|
||||
'planner_id' => $data['planner_id'] ?? 'N/A',
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
app/Actions/User/DeleteUserAction.php
Normal file
79
app/Actions/User/DeleteUserAction.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DeleteUserAction
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function execute(User $user): bool
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
Log::info('DeleteUserAction: Starting user deletion', [
|
||||
'user_id' => $user->id,
|
||||
'user_name' => $user->name,
|
||||
'planner_id' => $user->planner_id,
|
||||
]);
|
||||
|
||||
// Check for related data
|
||||
$userDishCount = $user->userDishes()->count();
|
||||
$dishCount = $user->dishes()->count();
|
||||
|
||||
Log::info('DeleteUserAction: User relationship counts', [
|
||||
'user_id' => $user->id,
|
||||
'user_dishes_count' => $userDishCount,
|
||||
'dishes_count' => $dishCount,
|
||||
]);
|
||||
|
||||
// Store user info before deletion for verification
|
||||
$userId = $user->id;
|
||||
$userName = $user->name;
|
||||
|
||||
// Delete the user (cascading deletes should handle related records)
|
||||
$result = $user->delete();
|
||||
|
||||
Log::info('DeleteUserAction: Delete result', [
|
||||
'result' => $result,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
if (! $result) {
|
||||
throw new Exception('User deletion returned false');
|
||||
}
|
||||
|
||||
// Verify the deletion actually happened
|
||||
$stillExists = User::find($userId);
|
||||
if ($stillExists) {
|
||||
throw new Exception('User deletion did not persist to database');
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
Log::info('DeleteUserAction: User successfully deleted', [
|
||||
'user_id' => $userId,
|
||||
'user_name' => $userName,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error('DeleteUserAction: User deletion failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/Actions/User/EditUserAction.php
Normal file
63
app/Actions/User/EditUserAction.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EditUserAction
|
||||
{
|
||||
public function execute(User $user, array $data): bool
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
Log::info('EditUserAction: Starting user update', [
|
||||
'user_id' => $user->id,
|
||||
'old_name' => $user->name,
|
||||
'new_name' => $data['name'],
|
||||
'planner_id' => $user->planner_id,
|
||||
]);
|
||||
|
||||
$result = $user->update([
|
||||
'name' => $data['name'],
|
||||
]);
|
||||
|
||||
Log::info('EditUserAction: Update result', [
|
||||
'result' => $result,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
if (!$result) {
|
||||
throw new \Exception('User update returned false');
|
||||
}
|
||||
|
||||
// Verify the update actually happened
|
||||
$user->refresh();
|
||||
if ($user->name !== $data['name']) {
|
||||
throw new \Exception('User update did not persist to database');
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
Log::info('EditUserAction: User successfully updated', [
|
||||
'user_id' => $user->id,
|
||||
'updated_name' => $user->name,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error('EditUserAction: User update failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/Enums/AppModeEnum.php
Normal file
24
app/Enums/AppModeEnum.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AppModeEnum: string
|
||||
{
|
||||
case APP = 'app';
|
||||
case SAAS = 'saas';
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
return self::from(config('app.mode', 'app'));
|
||||
}
|
||||
|
||||
public function isApp(): bool
|
||||
{
|
||||
return $this === self::APP;
|
||||
}
|
||||
|
||||
public function isSaas(): bool
|
||||
{
|
||||
return $this === self::SAAS;
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Auth/LoginController.php
Normal file
44
app/Http/Controllers/Auth/LoginController.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function showLoginForm()
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'These credentials do not match our records.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
37
app/Http/Controllers/Auth/RegisterController.php
Normal file
37
app/Http/Controllers/Auth/RegisterController.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Planner;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
public function showRegistrationForm()
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
public function register(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:planners'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = Planner::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard'));
|
||||
}
|
||||
}
|
||||
112
app/Http/Controllers/SubscriptionController.php
Normal file
112
app/Http/Controllers/SubscriptionController.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SubscriptionController extends Controller
|
||||
{
|
||||
public function checkout(Request $request)
|
||||
{
|
||||
$planner = $request->user();
|
||||
|
||||
if ($planner->subscribed()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
$plan = $request->input('plan', 'monthly');
|
||||
$priceId = $plan === 'yearly'
|
||||
? config('services.stripe.price_yearly')
|
||||
: config('services.stripe.price_monthly');
|
||||
|
||||
return $planner->newSubscription('default', $priceId)
|
||||
->checkout([
|
||||
'success_url' => route('subscription.success') . '?session_id={CHECKOUT_SESSION_ID}',
|
||||
'cancel_url' => route('subscription.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function success(Request $request): RedirectResponse
|
||||
{
|
||||
$sessionId = $request->query('session_id');
|
||||
|
||||
if ($sessionId) {
|
||||
$planner = $request->user();
|
||||
$session = Cashier::stripe()->checkout->sessions->retrieve($sessionId, [
|
||||
'expand' => ['subscription'],
|
||||
]);
|
||||
|
||||
if ($session->subscription && ! $planner->subscribed()) {
|
||||
$subscription = $session->subscription;
|
||||
|
||||
$planner->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => $subscription->id,
|
||||
'stripe_status' => $subscription->status,
|
||||
'stripe_price' => $subscription->items->data[0]->price->id ?? null,
|
||||
'quantity' => $subscription->items->data[0]->quantity ?? 1,
|
||||
'trial_ends_at' => $subscription->trial_end ? now()->setTimestamp($subscription->trial_end) : null,
|
||||
'ends_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('dashboard')->with('success', 'Subscription activated!');
|
||||
}
|
||||
|
||||
public function billing(Request $request)
|
||||
{
|
||||
$planner = $request->user();
|
||||
$subscription = $planner->subscription();
|
||||
|
||||
if (! $subscription) {
|
||||
return redirect()->route('subscription.index');
|
||||
}
|
||||
|
||||
$planType = match ($subscription->stripe_price) {
|
||||
config('services.stripe.price_yearly') => 'Yearly',
|
||||
config('services.stripe.price_monthly') => 'Monthly',
|
||||
default => 'Unknown',
|
||||
};
|
||||
|
||||
$nextBillingDate = null;
|
||||
if ($subscription->stripe_status === 'active') {
|
||||
try {
|
||||
$stripeSubscription = Cashier::stripe()->subscriptions->retrieve($subscription->stripe_id);
|
||||
$nextBillingDate = $stripeSubscription->current_period_end
|
||||
? now()->setTimestamp($stripeSubscription->current_period_end)
|
||||
: null;
|
||||
} catch (\Exception $e) {
|
||||
// Stripe API error - continue without next billing date
|
||||
}
|
||||
}
|
||||
|
||||
return view('billing.index', [
|
||||
'subscription' => $subscription,
|
||||
'planner' => $planner,
|
||||
'planType' => $planType,
|
||||
'nextBillingDate' => $nextBillingDate,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(Request $request): RedirectResponse
|
||||
{
|
||||
$planner = $request->user();
|
||||
|
||||
if (! $planner->subscribed()) {
|
||||
return back()->with('error', 'No active subscription found.');
|
||||
}
|
||||
|
||||
$planner->subscription()->cancel();
|
||||
|
||||
return back()->with('success', 'Subscription canceled. Access will continue until the end of your billing period.');
|
||||
}
|
||||
|
||||
public function billingPortal(Request $request)
|
||||
{
|
||||
return $request->user()->redirectToBillingPortal(route('billing'));
|
||||
}
|
||||
}
|
||||
20
app/Http/Middleware/ForceJsonResponse.php
Normal file
20
app/Http/Middleware/ForceJsonResponse.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ForceJsonResponse
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Only force JSON for API routes
|
||||
if ($request->is('api/*')) {
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,12 +6,14 @@
|
|||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ForceJsonResponse
|
||||
class RequireSaasMode
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
if (! is_mode_saas()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/Http/Middleware/RequireSubscription.php
Normal file
25
app/Http/Middleware/RequireSubscription.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RequireSubscription
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (is_mode_app()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$planner = $request->user();
|
||||
|
||||
if (! $planner?->subscribed()) {
|
||||
return redirect()->route('subscription.index');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
132
app/Livewire/Dishes/DishesList.php
Normal file
132
app/Livewire/Dishes/DishesList.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Dishes;
|
||||
|
||||
use App\Models\Dish;
|
||||
use App\Models\User;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class DishesList extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public $showCreateModal = false;
|
||||
public $showEditModal = false;
|
||||
public $showDeleteModal = false;
|
||||
|
||||
public $editingDish = null;
|
||||
public $deletingDish = null;
|
||||
|
||||
// Form fields
|
||||
public $name = '';
|
||||
public $selectedUsers = [];
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'selectedUsers' => 'array',
|
||||
];
|
||||
|
||||
public function render()
|
||||
{
|
||||
$dishes = Dish::with('users')
|
||||
->orderBy('name')
|
||||
->paginate(10);
|
||||
|
||||
$users = User::where('planner_id', auth()->id())
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('livewire.dishes.dishes-list', [
|
||||
'dishes' => $dishes,
|
||||
'users' => $users
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->reset(['name', 'selectedUsers']);
|
||||
$this->resetValidation();
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$dish = Dish::create([
|
||||
'name' => $this->name,
|
||||
'planner_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Attach selected users
|
||||
if (!empty($this->selectedUsers)) {
|
||||
$dish->users()->attach($this->selectedUsers);
|
||||
}
|
||||
|
||||
$this->showCreateModal = false;
|
||||
$this->reset(['name', 'selectedUsers']);
|
||||
|
||||
session()->flash('success', 'Dish created successfully.');
|
||||
}
|
||||
|
||||
public function edit(Dish $dish)
|
||||
{
|
||||
$this->editingDish = $dish;
|
||||
$this->name = $dish->name;
|
||||
$this->selectedUsers = $dish->users->pluck('id')->toArray();
|
||||
$this->resetValidation();
|
||||
$this->showEditModal = true;
|
||||
}
|
||||
|
||||
public function update()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$this->editingDish->update([
|
||||
'name' => $this->name,
|
||||
]);
|
||||
|
||||
// Sync users
|
||||
$this->editingDish->users()->sync($this->selectedUsers);
|
||||
|
||||
$this->showEditModal = false;
|
||||
$this->reset(['name', 'selectedUsers', 'editingDish']);
|
||||
|
||||
session()->flash('success', 'Dish updated successfully.');
|
||||
}
|
||||
|
||||
public function confirmDelete(Dish $dish)
|
||||
{
|
||||
$this->deletingDish = $dish;
|
||||
$this->showDeleteModal = true;
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
$this->deletingDish->users()->detach();
|
||||
$this->deletingDish->delete();
|
||||
$this->showDeleteModal = false;
|
||||
$this->deletingDish = null;
|
||||
|
||||
session()->flash('success', 'Dish deleted successfully.');
|
||||
}
|
||||
|
||||
public function cancel()
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
$this->showEditModal = false;
|
||||
$this->showDeleteModal = false;
|
||||
$this->reset(['name', 'selectedUsers', 'editingDish', 'deletingDish']);
|
||||
}
|
||||
|
||||
public function toggleAllUsers(): void
|
||||
{
|
||||
$users = User::where('planner_id', auth()->id())->get();
|
||||
if (count($this->selectedUsers) === $users->count()) {
|
||||
$this->selectedUsers = [];
|
||||
} else {
|
||||
$this->selectedUsers = $users->pluck('id')->map(fn($id) => (string) $id)->toArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
433
app/Livewire/Schedule/ScheduleCalendar.php
Normal file
433
app/Livewire/Schedule/ScheduleCalendar.php
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Schedule;
|
||||
|
||||
use App\Models\Dish;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDish;
|
||||
use Carbon\Carbon;
|
||||
use DishPlanner\Schedule\Services\ScheduleCalendarService;
|
||||
use DishPlanner\ScheduledUserDish\Actions\DeleteScheduledUserDishForDateAction;
|
||||
use DishPlanner\ScheduledUserDish\Actions\SkipScheduledUserDishForDateAction;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Component;
|
||||
|
||||
class ScheduleCalendar extends Component
|
||||
{
|
||||
public $currentMonth;
|
||||
public $currentYear;
|
||||
public $calendarDays = [];
|
||||
public $showRegenerateModal = false;
|
||||
public $regenerateDate = null;
|
||||
public $regenerateUserId = null;
|
||||
|
||||
// Edit dish modal
|
||||
public $showEditDishModal = false;
|
||||
public $editDate = null;
|
||||
public $editUserId = null;
|
||||
public $selectedDishId = null;
|
||||
public $availableDishes = [];
|
||||
|
||||
// Add dish modal
|
||||
public $showAddDishModal = false;
|
||||
public $addDate = null;
|
||||
public $addUserIds = [];
|
||||
public $addSelectedDishId = null;
|
||||
public $addAvailableUsers = [];
|
||||
public $addAvailableDishes = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->currentMonth = now()->month;
|
||||
$this->currentYear = now()->year;
|
||||
$this->loadCalendar();
|
||||
}
|
||||
|
||||
protected $listeners = ['schedule-generated' => 'refreshCalendar'];
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.schedule.schedule-calendar');
|
||||
}
|
||||
|
||||
public function refreshCalendar(): void
|
||||
{
|
||||
$this->loadCalendar();
|
||||
}
|
||||
|
||||
public function loadCalendar(): void
|
||||
{
|
||||
$service = new ScheduleCalendarService();
|
||||
$this->calendarDays = $service->getCalendarDays(
|
||||
auth()->user(),
|
||||
$this->currentMonth,
|
||||
$this->currentYear
|
||||
);
|
||||
}
|
||||
|
||||
public function previousMonth(): void
|
||||
{
|
||||
if ($this->currentMonth === 1) {
|
||||
$this->currentMonth = 12;
|
||||
$this->currentYear--;
|
||||
} else {
|
||||
$this->currentMonth--;
|
||||
}
|
||||
$this->loadCalendar();
|
||||
}
|
||||
|
||||
public function nextMonth(): void
|
||||
{
|
||||
if ($this->currentMonth === 12) {
|
||||
$this->currentMonth = 1;
|
||||
$this->currentYear++;
|
||||
} else {
|
||||
$this->currentMonth++;
|
||||
}
|
||||
$this->loadCalendar();
|
||||
}
|
||||
|
||||
public function regenerateForUserDate($date, $userId): void
|
||||
{
|
||||
if (!$this->authorizeUser($userId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->regenerateDate = $date;
|
||||
$this->regenerateUserId = $userId;
|
||||
$this->showRegenerateModal = true;
|
||||
}
|
||||
|
||||
public function confirmRegenerate(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->authorizeUser($this->regenerateUserId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
$action = new DeleteScheduledUserDishForDateAction();
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
Carbon::parse($this->regenerateDate),
|
||||
$this->regenerateUserId
|
||||
);
|
||||
|
||||
$this->showRegenerateModal = false;
|
||||
$this->loadCalendar();
|
||||
|
||||
session()->flash('success', 'Schedule regenerated for the selected date!');
|
||||
} catch (Exception $e) {
|
||||
Log::error('Schedule regeneration failed', ['exception' => $e, 'date' => $this->regenerateDate]);
|
||||
session()->flash('error', 'Unable to regenerate schedule. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function skipDay($date, $userId): void
|
||||
{
|
||||
try {
|
||||
if (!$this->authorizeUser($userId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
$action = new SkipScheduledUserDishForDateAction();
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
Carbon::parse($date),
|
||||
$userId
|
||||
);
|
||||
|
||||
$this->loadCalendar();
|
||||
|
||||
session()->flash('success', 'Day skipped successfully!');
|
||||
} catch (Exception $e) {
|
||||
Log::error('Skip day failed', ['exception' => $e, 'date' => $date, 'userId' => $userId]);
|
||||
session()->flash('error', 'Unable to skip day. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeUser(int $userId): bool
|
||||
{
|
||||
$user = User::find($userId);
|
||||
return $user && $user->planner_id === auth()->id();
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->showRegenerateModal = false;
|
||||
$this->regenerateDate = null;
|
||||
$this->regenerateUserId = null;
|
||||
$this->showEditDishModal = false;
|
||||
$this->editDate = null;
|
||||
$this->editUserId = null;
|
||||
$this->selectedDishId = null;
|
||||
$this->availableDishes = [];
|
||||
$this->showAddDishModal = false;
|
||||
$this->addDate = null;
|
||||
$this->addUserIds = [];
|
||||
$this->addSelectedDishId = null;
|
||||
$this->addAvailableUsers = [];
|
||||
$this->addAvailableDishes = [];
|
||||
}
|
||||
|
||||
public function removeDish($date, $userId): void
|
||||
{
|
||||
try {
|
||||
if (!$this->authorizeUser($userId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
$schedule = Schedule::where('planner_id', auth()->id())
|
||||
->where('date', $date)
|
||||
->first();
|
||||
|
||||
if ($schedule) {
|
||||
ScheduledUserDish::where('schedule_id', $schedule->id)
|
||||
->where('user_id', $userId)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$this->loadCalendar();
|
||||
session()->flash('success', 'Dish removed successfully!');
|
||||
} catch (Exception $e) {
|
||||
Log::error('Remove dish failed', ['exception' => $e, 'date' => $date, 'userId' => $userId]);
|
||||
session()->flash('error', 'Unable to remove dish. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function openAddDishModal($date): void
|
||||
{
|
||||
$this->addDate = $date;
|
||||
|
||||
// Load all users for this planner
|
||||
$this->addAvailableUsers = User::where('planner_id', auth()->id())
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$this->addAvailableDishes = [];
|
||||
$this->addUserIds = [];
|
||||
$this->addSelectedDishId = null;
|
||||
|
||||
$this->showAddDishModal = true;
|
||||
}
|
||||
|
||||
public function toggleAllUsers(): void
|
||||
{
|
||||
if (count($this->addUserIds) === count($this->addAvailableUsers)) {
|
||||
$this->addUserIds = [];
|
||||
} else {
|
||||
$this->addUserIds = $this->addAvailableUsers->pluck('id')->map(fn($id) => (string) $id)->toArray();
|
||||
}
|
||||
$this->updateAvailableDishes();
|
||||
}
|
||||
|
||||
public function updatedAddUserIds(): void
|
||||
{
|
||||
$this->updateAvailableDishes();
|
||||
}
|
||||
|
||||
private function updateAvailableDishes(): void
|
||||
{
|
||||
if (empty($this->addUserIds)) {
|
||||
$this->addAvailableDishes = [];
|
||||
} else {
|
||||
// Load dishes that ALL selected users have in common
|
||||
$selectedCount = count($this->addUserIds);
|
||||
$this->addAvailableDishes = Dish::whereHas('users', function ($query) {
|
||||
$query->whereIn('users.id', $this->addUserIds);
|
||||
}, '=', $selectedCount)->orderBy('name')->get();
|
||||
}
|
||||
$this->addSelectedDishId = null;
|
||||
}
|
||||
|
||||
public function saveAddDish(): void
|
||||
{
|
||||
try {
|
||||
if (empty($this->addUserIds)) {
|
||||
session()->flash('error', 'Please select at least one user.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->addSelectedDishId) {
|
||||
session()->flash('error', 'Please select a dish.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find or create the schedule for this date
|
||||
$schedule = Schedule::firstOrCreate(
|
||||
[
|
||||
'planner_id' => auth()->id(),
|
||||
'date' => $this->addDate,
|
||||
],
|
||||
['is_skipped' => false]
|
||||
);
|
||||
|
||||
$addedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($this->addUserIds as $userId) {
|
||||
if (!$this->authorizeUser((int) $userId)) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if user already has a dish scheduled for this date
|
||||
$existing = ScheduledUserDish::where('schedule_id', $schedule->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the UserDish for this user and dish
|
||||
$userDish = UserDish::where('user_id', $userId)
|
||||
->where('dish_id', $this->addSelectedDishId)
|
||||
->first();
|
||||
|
||||
if (!$userDish) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the scheduled user dish
|
||||
ScheduledUserDish::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'user_id' => $userId,
|
||||
'user_dish_id' => $userDish->id,
|
||||
'is_skipped' => false,
|
||||
]);
|
||||
$addedCount++;
|
||||
}
|
||||
|
||||
$this->closeAddDishModal();
|
||||
$this->loadCalendar();
|
||||
|
||||
if ($addedCount > 0 && $skippedCount > 0) {
|
||||
session()->flash('success', "Dish added for {$addedCount} user(s). {$skippedCount} user(s) skipped (already scheduled).");
|
||||
} elseif ($addedCount > 0) {
|
||||
session()->flash('success', "Dish added for {$addedCount} user(s)!");
|
||||
} else {
|
||||
session()->flash('error', 'No users could be scheduled. They may already have dishes for this date.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error('Add dish failed', ['exception' => $e]);
|
||||
session()->flash('error', 'Unable to add dish. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
private function closeAddDishModal(): void
|
||||
{
|
||||
$this->showAddDishModal = false;
|
||||
$this->addDate = null;
|
||||
$this->addUserIds = [];
|
||||
$this->addSelectedDishId = null;
|
||||
$this->addAvailableUsers = [];
|
||||
$this->addAvailableDishes = [];
|
||||
}
|
||||
|
||||
public function editDish($date, $userId): void
|
||||
{
|
||||
if (!$this->authorizeUser($userId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editDate = $date;
|
||||
$this->editUserId = $userId;
|
||||
|
||||
// Load dishes available for this user (via UserDish pivot)
|
||||
$this->availableDishes = Dish::whereHas('users', function ($query) use ($userId) {
|
||||
$query->where('users.id', $userId);
|
||||
})->orderBy('name')->get();
|
||||
|
||||
// Get currently selected dish for this date/user if exists
|
||||
$schedule = Schedule::where('planner_id', auth()->id())
|
||||
->where('date', $date)
|
||||
->first();
|
||||
|
||||
if ($schedule) {
|
||||
$scheduledUserDish = ScheduledUserDish::where('schedule_id', $schedule->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($scheduledUserDish && $scheduledUserDish->userDish) {
|
||||
$this->selectedDishId = $scheduledUserDish->userDish->dish_id;
|
||||
}
|
||||
}
|
||||
|
||||
$this->showEditDishModal = true;
|
||||
}
|
||||
|
||||
public function saveDish(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->authorizeUser($this->editUserId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->selectedDishId) {
|
||||
session()->flash('error', 'Please select a dish.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find or create the schedule for this date
|
||||
$schedule = Schedule::firstOrCreate(
|
||||
[
|
||||
'planner_id' => auth()->id(),
|
||||
'date' => $this->editDate,
|
||||
],
|
||||
['is_skipped' => false]
|
||||
);
|
||||
|
||||
// Find the UserDish for this user and dish
|
||||
$userDish = UserDish::where('user_id', $this->editUserId)
|
||||
->where('dish_id', $this->selectedDishId)
|
||||
->first();
|
||||
|
||||
if (!$userDish) {
|
||||
session()->flash('error', 'This dish is not assigned to this user.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update or create the scheduled user dish
|
||||
ScheduledUserDish::updateOrCreate(
|
||||
[
|
||||
'schedule_id' => $schedule->id,
|
||||
'user_id' => $this->editUserId,
|
||||
],
|
||||
[
|
||||
'user_dish_id' => $userDish->id,
|
||||
'is_skipped' => false,
|
||||
]
|
||||
);
|
||||
|
||||
$this->showEditDishModal = false;
|
||||
$this->editDate = null;
|
||||
$this->editUserId = null;
|
||||
$this->selectedDishId = null;
|
||||
$this->availableDishes = [];
|
||||
|
||||
$this->loadCalendar();
|
||||
session()->flash('success', 'Dish updated successfully!');
|
||||
} catch (Exception $e) {
|
||||
Log::error('Save dish failed', ['exception' => $e]);
|
||||
session()->flash('error', 'Unable to save dish. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function getMonthNameProperty(): string
|
||||
{
|
||||
$service = new ScheduleCalendarService();
|
||||
return $service->getMonthName($this->currentMonth, $this->currentYear);
|
||||
}
|
||||
}
|
||||
141
app/Livewire/Schedule/ScheduleGenerator.php
Normal file
141
app/Livewire/Schedule/ScheduleGenerator.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Schedule;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use DishPlanner\Schedule\Actions\ClearScheduleForMonthAction;
|
||||
use DishPlanner\Schedule\Actions\GenerateScheduleForMonthAction;
|
||||
use DishPlanner\Schedule\Actions\RegenerateScheduleForDateForUsersAction;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Component;
|
||||
|
||||
class ScheduleGenerator extends Component
|
||||
{
|
||||
private const YEARS_IN_PAST = 1;
|
||||
private const YEARS_IN_FUTURE = 5;
|
||||
|
||||
public $selectedMonth;
|
||||
public $selectedYear;
|
||||
public $selectedUsers = [];
|
||||
public $clearExisting = true;
|
||||
public $showAdvancedOptions = false;
|
||||
public $isGenerating = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->selectedMonth = now()->month;
|
||||
$this->selectedYear = now()->year;
|
||||
|
||||
$this->selectedUsers = User::where('planner_id', auth()->id())
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
|
||||
{
|
||||
$users = User::where('planner_id', auth()->id())
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$years = range(now()->year - self::YEARS_IN_PAST, now()->year + self::YEARS_IN_FUTURE);
|
||||
|
||||
return view('livewire.schedule.schedule-generator', [
|
||||
'users' => $users,
|
||||
'months' => $this->getMonthNames(),
|
||||
'years' => $years
|
||||
]);
|
||||
}
|
||||
|
||||
public function generate(): void
|
||||
{
|
||||
$this->validate([
|
||||
'selectedUsers' => 'required|array|min:1',
|
||||
'selectedMonth' => 'required|integer|min:1|max:12',
|
||||
'selectedYear' => 'required|integer|min:' . (now()->year - self::YEARS_IN_PAST) . '|max:' . (now()->year + self::YEARS_IN_FUTURE),
|
||||
]);
|
||||
|
||||
$this->isGenerating = true;
|
||||
|
||||
try {
|
||||
$action = new GenerateScheduleForMonthAction();
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
$this->selectedMonth,
|
||||
$this->selectedYear,
|
||||
$this->selectedUsers,
|
||||
$this->clearExisting
|
||||
);
|
||||
|
||||
$this->isGenerating = false;
|
||||
$this->dispatch('schedule-generated');
|
||||
|
||||
session()->flash('success', 'Schedule generated successfully for ' .
|
||||
$this->getSelectedMonthName() . ' ' . $this->selectedYear);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->isGenerating = false;
|
||||
Log::error('Schedule generation failed', ['exception' => $e]);
|
||||
session()->flash('error', 'Unable to generate schedule. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function regenerateForDate($date): void
|
||||
{
|
||||
try {
|
||||
$action = new RegenerateScheduleForDateForUsersAction();
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
Carbon::parse($date),
|
||||
$this->selectedUsers
|
||||
);
|
||||
|
||||
$this->dispatch('schedule-generated');
|
||||
session()->flash('success', 'Schedule regenerated for ' . Carbon::parse($date)->format('M d, Y'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Schedule regeneration failed', ['exception' => $e, 'date' => $date]);
|
||||
session()->flash('error', 'Unable to regenerate schedule. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function clearMonth(): void
|
||||
{
|
||||
try {
|
||||
$action = new ClearScheduleForMonthAction();
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
$this->selectedMonth,
|
||||
$this->selectedYear,
|
||||
$this->selectedUsers
|
||||
);
|
||||
|
||||
$this->dispatch('schedule-generated');
|
||||
session()->flash('success', 'Schedule cleared for ' .
|
||||
$this->getSelectedMonthName() . ' ' . $this->selectedYear);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Clear month failed', ['exception' => $e]);
|
||||
session()->flash('error', 'Unable to clear schedule. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleAdvancedOptions()
|
||||
{
|
||||
$this->showAdvancedOptions = !$this->showAdvancedOptions;
|
||||
}
|
||||
|
||||
private function getMonthNames(): array
|
||||
{
|
||||
return [
|
||||
1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April',
|
||||
5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August',
|
||||
9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December'
|
||||
];
|
||||
}
|
||||
|
||||
private function getSelectedMonthName(): string
|
||||
{
|
||||
return $this->getMonthNames()[$this->selectedMonth];
|
||||
}
|
||||
}
|
||||
126
app/Livewire/Users/UsersList.php
Normal file
126
app/Livewire/Users/UsersList.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Users;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Actions\User\CreateUserAction;
|
||||
use App\Actions\User\DeleteUserAction;
|
||||
use App\Actions\User\EditUserAction;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class UsersList extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public bool $showCreateModal = false;
|
||||
public bool $showEditModal = false;
|
||||
public bool $showDeleteModal = false;
|
||||
|
||||
public ?User $editingUser = null;
|
||||
public ?User $deletingUser = null;
|
||||
|
||||
// Form fields
|
||||
public string $name = '';
|
||||
|
||||
protected array $rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
];
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$users = User::where('planner_id', auth()->id())
|
||||
->orderBy('name')
|
||||
->paginate(10);
|
||||
|
||||
return view('livewire.users.users-list', [
|
||||
'users' => $users
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->reset(['name']);
|
||||
$this->resetValidation();
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
(new CreateUserAction())->execute([
|
||||
'name' => $this->name,
|
||||
'planner_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
$this->showCreateModal = false;
|
||||
$this->reset(['name']);
|
||||
|
||||
session()->flash('success', 'User created successfully.');
|
||||
} catch (Exception $e) {
|
||||
session()->flash('error', 'Failed to create user: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function edit(User $user): void
|
||||
{
|
||||
$this->editingUser = $user;
|
||||
$this->name = $user->name;
|
||||
$this->resetValidation();
|
||||
$this->showEditModal = true;
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
(new EditUserAction())->execute($this->editingUser, ['name' => $this->name]);
|
||||
|
||||
$this->showEditModal = false;
|
||||
$this->reset(['name', 'editingUser']);
|
||||
|
||||
session()->flash('success', 'User updated successfully.');
|
||||
|
||||
// Force component to re-render with fresh data
|
||||
$this->resetPage();
|
||||
} catch (Exception $e) {
|
||||
session()->flash('error', 'Failed to update user: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmDelete(User $user): void
|
||||
{
|
||||
$this->deletingUser = $user;
|
||||
$this->showDeleteModal = true;
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
try {
|
||||
(new DeleteUserAction())->execute($this->deletingUser);
|
||||
|
||||
$this->showDeleteModal = false;
|
||||
$this->deletingUser = null;
|
||||
|
||||
session()->flash('success', 'User deleted successfully.');
|
||||
|
||||
// Force component to re-render with fresh data
|
||||
$this->resetPage();
|
||||
} catch (Exception $e) {
|
||||
session()->flash('error', 'Failed to delete user: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
$this->showEditModal = false;
|
||||
$this->showDeleteModal = false;
|
||||
$this->reset(['name', 'editingUser', 'deletingUser']);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,15 +6,17 @@
|
|||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property static PlannerFactory factory($count = null, $state = [])
|
||||
* @method static first()
|
||||
*/
|
||||
class Planner extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
use Billable, HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'email', 'password',
|
||||
|
|
@ -24,6 +26,10 @@ class Planner extends Authenticatable
|
|||
'password', 'remember_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'password' => 'hashed',
|
||||
];
|
||||
|
||||
public function schedules(): HasMany
|
||||
{
|
||||
return $this->hasMany(Schedule::class);
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
* @method static create(array $array)
|
||||
* @method static Builder where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and')
|
||||
* @method static ScheduleFactory factory($count = null, $state = [])
|
||||
* @method static firstOrCreate(array $array, false[] $array1)
|
||||
*/
|
||||
class Schedule extends Model
|
||||
{
|
||||
|
|
@ -56,6 +57,6 @@ public function scheduledUserDishes(): HasMany
|
|||
|
||||
public function hasAllUsersScheduled(): bool
|
||||
{
|
||||
return $this->scheduledUserDishes->count() === User::all()->count();
|
||||
return $this->scheduledUserDishes->count() === User::where('planner_id', $this->planner_id)->count();
|
||||
}
|
||||
}
|
||||
|
|
@ -12,17 +12,25 @@
|
|||
* @property int $id
|
||||
* @property int $schedule_id
|
||||
* @property Schedule $schedule
|
||||
* @property int $user_id
|
||||
* @property User $user
|
||||
* @property int $user_dish_id
|
||||
* @property UserDish $userDish
|
||||
* @property bool $is_skipped
|
||||
* @method static create(array $array)
|
||||
* @method static ScheduledUserDishFactory factory($count = null, $state = [])
|
||||
* @method static firstOrCreate(array $array, array $array1)
|
||||
*/
|
||||
class ScheduledUserDish extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['schedule_id', 'user_id', 'user_dish_id', 'is_skipped'];
|
||||
protected $fillable = [
|
||||
'schedule_id',
|
||||
'user_id',
|
||||
'user_dish_id',
|
||||
'is_skipped'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_skipped' => 'boolean',
|
||||
|
|
@ -9,8 +9,7 @@
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
|
|
@ -20,22 +19,17 @@
|
|||
* @property Collection<UserDish> $userDishes
|
||||
* @method static User findOrFail(int $user_id)
|
||||
* @method static UserFactory factory($count = null, $state = [])
|
||||
* @method static create(array $array)
|
||||
* @method static where(string $string, int|string|null $id)
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
class User extends Model
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'planner_id',
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
|
|
@ -43,19 +37,6 @@ protected static function booted(): void
|
|||
static::addGlobalScope(new BelongsToPlanner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function dishes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Dish::class, 'user_dishes', 'user_id', 'dish_id');
|
||||
|
|
@ -4,10 +4,12 @@
|
|||
|
||||
use App\Exceptions\CustomException;
|
||||
use App\Models\Dish;
|
||||
use App\Models\Planner;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDish;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use DishPlanner\Dish\Policies\DishPolicy;
|
||||
use DishPlanner\Schedule\Policies\SchedulePolicy;
|
||||
use DishPlanner\ScheduledUserDish\Policies\ScheduledUserDishPolicy;
|
||||
|
|
@ -45,6 +47,8 @@ public function render($request, Throwable $e)
|
|||
|
||||
public function boot(): void
|
||||
{
|
||||
Cashier::useCustomerModel(Planner::class);
|
||||
|
||||
Gate::policy(Dish::class, DishPolicy::class);
|
||||
Gate::policy(Schedule::class, SchedulePolicy::class);
|
||||
Gate::policy(ScheduledUserDish::class, ScheduledUserDishPolicy::class);
|
||||
17
app/helpers.php
Normal file
17
app/helpers.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\AppModeEnum;
|
||||
|
||||
if (! function_exists('is_mode_app')) {
|
||||
function is_mode_app(): bool
|
||||
{
|
||||
return AppModeEnum::current()->isApp();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('is_mode_saas')) {
|
||||
function is_mode_saas(): bool
|
||||
{
|
||||
return AppModeEnum::current()->isSaas();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
APP_NAME=DishPlanner
|
||||
APP_ENV=local
|
||||
APP_KEY=base64:Z3WnYIG9I6xxft15P1EO31WHinj1R36eM/iN3ouyFBM=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost:3000
|
||||
SESSION_DOMAIN=localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
WWWGROUP=1000
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=dishplanner
|
||||
DB_USERNAME=dpuser
|
||||
DB_PASSWORD=dppass
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=file
|
||||
CACHE_PREFIX=dishplanner
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
25
backend/.gitignore
vendored
25
backend/.gitignore
vendored
|
|
@ -1,25 +0,0 @@
|
|||
/composer.lock
|
||||
/.composer
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/auth.json
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# Use official PHP base with required extensions
|
||||
FROM php:8.2-fpm
|
||||
|
||||
# Install system dependencies & PHP extensions
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev zip \
|
||||
&& docker-php-ext-install pdo pdo_mysql zip mbstring exif pcntl bcmath
|
||||
|
||||
# Install Composer globally
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working dir
|
||||
WORKDIR /var/www
|
||||
|
||||
# Copy app files
|
||||
COPY . .
|
||||
|
||||
# Install PHP dependencies
|
||||
RUN composer install --no-dev --optimize-autoloader
|
||||
|
||||
# Laravel optimizations
|
||||
RUN php artisan config:cache \
|
||||
&& php artisan route:cache \
|
||||
&& php artisan view:cache
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R www-data:www-data /var/www \
|
||||
&& chmod -R 755 /var/www/storage
|
||||
|
||||
USER www-data
|
||||
|
||||
# Expose port 9000 (default for php-fpm)
|
||||
EXPOSE 9000
|
||||
|
||||
CMD ["php-fpm"]
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# Dish Planner
|
||||
|
||||
Plan your future dishes
|
||||
|
||||
## Development
|
||||
|
||||
### Installation
|
||||
|
||||
This project uses Laravel Sail, so install all composer packages, get your docker up, and run
|
||||
|
||||
```shell
|
||||
./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\ForceJsonResponse;
|
||||
use App\Http\Middleware\HandleResourceNotFound;
|
||||
use App\Services\OutputService;
|
||||
use DishPlanner\Dish\Controllers\DishController;
|
||||
use DishPlanner\Schedule\Console\Commands\GenerateScheduleCommand;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Middleware\HandleCors;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->append(ForceJsonResponse::class);
|
||||
$middleware->append(StartSession::class);
|
||||
$middleware->append(HandleCors::class);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||
if ($request->is('api/*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $request->expectsJson();
|
||||
});
|
||||
|
||||
/** @var OutputService $outputService */
|
||||
$outputService = resolve(OutputService::class);
|
||||
|
||||
$exceptions->render(fn (ValidationException $e, Request $request) => $outputService
|
||||
->response(false, null, [$e->getMessage()], 404)
|
||||
);
|
||||
|
||||
$exceptions->render(fn (NotFoundHttpException $e, Request $request) => response()->json(
|
||||
$outputService->response(false, null, ['MODEL_NOT_FOUND']),
|
||||
404
|
||||
));
|
||||
|
||||
$exceptions->render(fn (AccessDeniedHttpException $e, Request $request) => response()->json(
|
||||
$outputService->response(false, null, [$e->getMessage()]),
|
||||
403
|
||||
));
|
||||
})
|
||||
->withCommands([
|
||||
GenerateScheduleCommand::class,
|
||||
])
|
||||
->create();
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Dish;
|
||||
use App\Models\Planner;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DishesSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$users = User::all();
|
||||
$userOptions = collect([
|
||||
[$users->first()],
|
||||
[$users->last()],
|
||||
[$users->first(), $users->last()],
|
||||
]);
|
||||
|
||||
$planner = Planner::all()->first() ?? Planner::factory()->create();
|
||||
|
||||
collect([
|
||||
'lasagne', 'pizza', 'burger', 'fries', 'salad', 'sushi', 'pancakes', 'ice cream', 'spaghetti', 'mac and cheese',
|
||||
'steak', 'chicken', 'beef', 'pork', 'fish', 'chips', 'cake',
|
||||
])->map(fn (string $name) => Dish::factory()
|
||||
->create([
|
||||
'planner_id' => $planner->id,
|
||||
'name' => $name,
|
||||
])
|
||||
)->each(fn (Dish $dish) => $dish->users()->attach($userOptions->random()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
services:
|
||||
backend:
|
||||
volumes:
|
||||
- '.:/var/www/html:Z'
|
||||
|
||||
mysql:
|
||||
image: 'docker.io/library/mysql:8.0'
|
||||
environment:
|
||||
MYSQL_USER: '${DB_USERNAME}'
|
||||
MYSQL_PASSWORD: '${DB_PASSWORD}'
|
||||
volumes:
|
||||
- 'sail-mysql:/var/lib/mysql:Z'
|
||||
|
||||
frontend:
|
||||
image: 'docker.io/library/node:22-alpine'
|
||||
working_dir: /app
|
||||
command: sh -c "npm install && npm run dev -- --host"
|
||||
volumes:
|
||||
- '../frontend:/app:Z'
|
||||
ports:
|
||||
- '5173:5173'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- backend
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
services:
|
||||
backend:
|
||||
build:
|
||||
context: './vendor/laravel/sail/runtimes/8.4'
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP}'
|
||||
image: 'sail-8.4/app'
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
ports:
|
||||
- '${APP_PORT:-80}:80'
|
||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER}'
|
||||
LARAVEL_SAIL: 1
|
||||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||
volumes:
|
||||
- '.:/var/www/html:Z'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- mysql
|
||||
mysql:
|
||||
image: 'docker.io/mysql/mysql-server:8.0'
|
||||
ports:
|
||||
- '${FORWARD_DB_PORT:-3306}:3306'
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
|
||||
MYSQL_ROOT_HOST: '%'
|
||||
MYSQL_DATABASE: '${DB_DATABASE}'
|
||||
MYSQL_USER: '${DB_USERNAME}'
|
||||
MYSQL_PASSWORD: '${DB_PASSWORD}'
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
||||
volumes:
|
||||
- 'sail-mysql:/var/lib/mysql:Z'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- mysqladmin
|
||||
- ping
|
||||
- '-p${DB_PASSWORD}'
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
networks:
|
||||
sail:
|
||||
driver: bridge
|
||||
volumes:
|
||||
sail-mysql:
|
||||
driver: local
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.4",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"vite": "^6.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
});
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import defaultTheme from 'tailwindcss/defaultTheme';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
|
||||
'./storage/framework/views/*.php',
|
||||
'./resources/**/*.blade.php',
|
||||
'./resources/**/*.js',
|
||||
'./resources/**/*.vue',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Figtree', ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Planner;
|
||||
use App\Models\Schedule;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PlannerLoginTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_a_planner_can_log_in_with_correct_credentials(): void
|
||||
{
|
||||
$planner = Planner::factory()->create([
|
||||
'email' => 'planner@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
|
||||
$response = $this
|
||||
->actingAs($planner)
|
||||
->post(route('api.auth.login'), [
|
||||
'email' => 'planner@example.com',
|
||||
'password' => 'secret123',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertAuthenticatedAs($planner);
|
||||
}
|
||||
|
||||
public function test_login_fails_with_invalid_credentials(): void
|
||||
{
|
||||
Planner::factory()->create([
|
||||
'email' => 'planner@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('api.auth.login'), [
|
||||
'email' => 'planner@example.com',
|
||||
'password' => 'wrongpassword',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_a_logged_in_planner_can_log_out(): void
|
||||
{
|
||||
$planner = Planner::factory()->create([
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
|
||||
$this->post(route('api.auth.login'), [
|
||||
'email' => $planner->email,
|
||||
'password' => 'secret123',
|
||||
]);
|
||||
|
||||
$response = $this->post(route('api.auth.logout'));
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertGuest(); // nobody should be logged in after logout
|
||||
}
|
||||
|
||||
public function test_planner_can_register(): void
|
||||
{
|
||||
$schedulesCount = Schedule::all()->count();
|
||||
|
||||
$response = $this->post(route('api.auth.register'), [
|
||||
'name' => 'High Functioning Planner',
|
||||
'email' => 'planner@example.com',
|
||||
'password' => 'secret123',
|
||||
'password_confirmation' => 'secret123',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$this->assertDatabaseHas('planners', [
|
||||
'email' => 'planner@example.com',
|
||||
]);
|
||||
|
||||
$this->assertGreaterThan($schedulesCount, Schedule::all()->count());
|
||||
}
|
||||
|
||||
public function test_it_returns_the_authenticated_planner(): void
|
||||
{
|
||||
$planner = Planner::factory()->create();
|
||||
|
||||
$this
|
||||
->actingAs($planner)
|
||||
->get(route('api.auth.me'))
|
||||
->assertOk()
|
||||
->assertJsonFragment([
|
||||
'email' => $planner->email,
|
||||
'name' => $planner->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Traits;
|
||||
|
||||
use App\Models\Planner;
|
||||
|
||||
trait HasPlanner
|
||||
{
|
||||
protected Planner $planner;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$planner = Planner::factory()->create();
|
||||
|
||||
$this->planner = $planner;
|
||||
}
|
||||
|
||||
}
|
||||
29
bin/build-push.sh
Executable file
29
bin/build-push.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
# Build and push production image to Codeberg
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY="codeberg.org"
|
||||
NAMESPACE="lvl0"
|
||||
IMAGE_NAME="dish-planner"
|
||||
TAG="${1:-latest}"
|
||||
|
||||
echo "🔨 Building production image..."
|
||||
podman build -f Dockerfile -t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG} .
|
||||
|
||||
echo "📤 Pushing to Codeberg registry..."
|
||||
echo "Please ensure you're logged in to Codeberg:"
|
||||
echo " podman login codeberg.org"
|
||||
|
||||
podman push ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}
|
||||
|
||||
echo "✅ Done! Image pushed to ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}"
|
||||
echo ""
|
||||
echo "To deploy in production:"
|
||||
echo "1. Copy docker-compose.prod.yml to your server"
|
||||
echo "2. Set required environment variables:"
|
||||
echo " - APP_KEY (generate with: openssl rand -base64 32)"
|
||||
echo " - APP_URL"
|
||||
echo " - DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_ROOT_PASSWORD"
|
||||
echo "3. Run: docker-compose -f docker-compose.prod.yml up -d"
|
||||
80
bootstrap/app.php
Normal file
80
bootstrap/app.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\ForceJsonResponse;
|
||||
use App\Http\Middleware\RequireSaasMode;
|
||||
use App\Http\Middleware\RequireSubscription;
|
||||
use App\Services\OutputService;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
then: function () {
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/web/subscription.php'));
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
// Apply ForceJsonResponse only to API routes
|
||||
$middleware->api(ForceJsonResponse::class);
|
||||
|
||||
$middleware->alias([
|
||||
'subscription' => RequireSubscription::class,
|
||||
'saas' => RequireSaasMode::class,
|
||||
]);
|
||||
|
||||
// Exclude Stripe webhook from CSRF verification
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'stripe/webhook',
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||
if ($request->is('api/*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $request->expectsJson();
|
||||
});
|
||||
|
||||
/** @var OutputService $outputService */
|
||||
$outputService = resolve(OutputService::class);
|
||||
|
||||
$exceptions->render(function (ValidationException $e, Request $request) use ($outputService) {
|
||||
if ($request->is('api/*') || $request->expectsJson()) {
|
||||
return response()->json(
|
||||
$outputService->response(false, null, [$e->getMessage()]),
|
||||
404
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$exceptions->render(function (NotFoundHttpException $e, Request $request) use ($outputService) {
|
||||
if ($request->is('api/*') || $request->expectsJson()) {
|
||||
return response()->json(
|
||||
$outputService->response(false, null, ['MODEL_NOT_FOUND']),
|
||||
404
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$exceptions->render(function (AccessDeniedHttpException $e, Request $request) use ($outputService) {
|
||||
if ($request->is('api/*') || $request->expectsJson()) {
|
||||
return response()->json(
|
||||
$outputService->response(false, null, [$e->getMessage()]),
|
||||
403
|
||||
);
|
||||
}
|
||||
});
|
||||
})
|
||||
->create();
|
||||
0
backend/bootstrap/cache/.gitignore → bootstrap/cache/.gitignore
vendored
Normal file → Executable file
0
backend/bootstrap/cache/.gitignore → bootstrap/cache/.gitignore
vendored
Normal file → Executable file
29
build-push.sh
Executable file
29
build-push.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
# Build and push production image to Codeberg
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY="codeberg.org"
|
||||
NAMESPACE="lvl0"
|
||||
IMAGE_NAME="dish-planner"
|
||||
TAG="${1:-latest}"
|
||||
|
||||
echo "🔨 Building production image..."
|
||||
podman build -f Dockerfile -t ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG} .
|
||||
|
||||
echo "📤 Pushing to Codeberg registry..."
|
||||
echo "Please ensure you're logged in to Codeberg:"
|
||||
echo " podman login codeberg.org"
|
||||
|
||||
podman push ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}
|
||||
|
||||
echo "✅ Done! Image pushed to ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}"
|
||||
echo ""
|
||||
echo "To deploy in production:"
|
||||
echo "1. Copy docker-compose.prod.yml to your server"
|
||||
echo "2. Set required environment variables:"
|
||||
echo " - APP_KEY (generate with: openssl rand -base64 32)"
|
||||
echo " - APP_URL"
|
||||
echo " - DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_ROOT_PASSWORD"
|
||||
echo "3. Run: docker-compose -f docker-compose.prod.yml up -d"
|
||||
3
build_and_push.sh
Executable file
3
build_and_push.sh
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash
|
||||
docker build -t 192.168.178.152:50114/dishplanner-backend .
|
||||
docker push 192.168.178.152:50114/dishplanner-backend
|
||||
|
|
@ -10,12 +10,15 @@
|
|||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/cashier": "^16.1",
|
||||
"laravel/framework": "^12.9.2",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^2.9"
|
||||
"laravel/tinker": "^2.9",
|
||||
"livewire/livewire": "^3.7"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/dusk": "^8.3",
|
||||
"laravel/pail": "^1.1",
|
||||
"laravel/pint": "^1.13",
|
||||
"laravel/sail": "^1.26",
|
||||
|
|
@ -24,6 +27,9 @@
|
|||
"phpunit/phpunit": "^11.0.1"
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"app/helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"DishPlanner\\": "src/DishPlanner/",
|
||||
|
|
@ -55,6 +61,17 @@
|
|||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan test"
|
||||
],
|
||||
"test:coverage": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -d xdebug.mode=coverage artisan test --coverage"
|
||||
],
|
||||
"test:coverage-html": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -d xdebug.mode=coverage vendor/bin/phpunit --coverage-html coverage --coverage-text"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
'name' => env('APP_NAME', 'Dish Planner'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -28,6 +28,18 @@
|
|||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Determines the application deployment mode: 'app' for self-hosted,
|
||||
| 'saas' for multi-tenant SaaS, 'demo' for demonstration instances.
|
||||
|
|
||||
*/
|
||||
|
||||
'mode' => env('APP_MODE', 'app'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
186
config/livewire.php
Normal file
186
config/livewire.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Class Namespace
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value sets the root class namespace for Livewire component classes in
|
||||
| your application. This value will change where component auto-discovery
|
||||
| finds components. It's also referenced by the file creation commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'class_namespace' => 'App\\Livewire',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| View Path
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value is used to specify where Livewire component Blade templates are
|
||||
| stored when running file creation commands like `artisan make:livewire`.
|
||||
| It is also used if you choose to omit a component's render() method.
|
||||
|
|
||||
*/
|
||||
|
||||
'view_path' => resource_path('views/livewire'),
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Layout
|
||||
|---------------------------------------------------------------------------
|
||||
| The view that will be used as the layout when rendering a single component
|
||||
| as an entire page via `Route::get('/post/create', CreatePost::class);`.
|
||||
| In this case, the view returned by CreatePost will render into $slot.
|
||||
|
|
||||
*/
|
||||
|
||||
'layout' => 'components.layouts.app',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Lazy Loading Placeholder
|
||||
|---------------------------------------------------------------------------
|
||||
| Livewire allows you to lazy load components that would otherwise slow down
|
||||
| the initial page load. Every component can have a custom placeholder or
|
||||
| you can define the default placeholder view for all components below.
|
||||
|
|
||||
*/
|
||||
|
||||
'lazy_placeholder' => null,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Temporary File Uploads
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire handles file uploads by storing uploads in a temporary directory
|
||||
| before the file is stored permanently. All file uploads are directed to
|
||||
| a global endpoint for temporary storage. You may configure this below:
|
||||
|
|
||||
*/
|
||||
|
||||
'temporary_file_upload' => [
|
||||
'disk' => null, // Example: 'local', 's3' | Default: 'default'
|
||||
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
|
||||
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
|
||||
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
|
||||
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
|
||||
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
|
||||
'mov', 'avi', 'wmv', 'mp3', 'm4a',
|
||||
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
|
||||
],
|
||||
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
|
||||
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Render On Redirect
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines if Livewire will run a component's `render()` method
|
||||
| after a redirect has been triggered using something like `redirect(...)`
|
||||
| Setting this to true will render the view once more before redirecting
|
||||
|
|
||||
*/
|
||||
|
||||
'render_on_redirect' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Eloquent Model Binding
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Previous versions of Livewire supported binding directly to eloquent model
|
||||
| properties using wire:model by default. However, this behavior has been
|
||||
| deemed too "magical" and has therefore been put under a feature flag.
|
||||
|
|
||||
*/
|
||||
|
||||
'legacy_model_binding' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Auto-inject Frontend Assets
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Livewire automatically injects its JavaScript and CSS into the
|
||||
| <head> and <body> of pages containing Livewire components. By disabling
|
||||
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
||||
|
|
||||
*/
|
||||
|
||||
'inject_assets' => true,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Navigate (SPA mode)
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| By adding `wire:navigate` to links in your Livewire application, Livewire
|
||||
| will prevent the default link handling and instead request those pages
|
||||
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
||||
|
|
||||
*/
|
||||
|
||||
'navigate' => [
|
||||
'show_progress_bar' => true,
|
||||
'progress_bar_color' => '#2299dd',
|
||||
],
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| HTML Morph Markers
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
|
||||
| after each update. To make this process more reliable, Livewire injects
|
||||
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
||||
|
|
||||
*/
|
||||
|
||||
'inject_morph_markers' => true,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Smart Wire Keys
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire uses loops and keys used within loops to generate smart keys that
|
||||
| are applied to nested components that don't have them. This makes using
|
||||
| nested components more reliable by ensuring that they all have keys.
|
||||
|
|
||||
*/
|
||||
|
||||
'smart_wire_keys' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Pagination Theme
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| When enabling Livewire's pagination feature by using the `WithPagination`
|
||||
| trait, Livewire will use Tailwind templates to render pagination views
|
||||
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
||||
|
|
||||
*/
|
||||
|
||||
'pagination_theme' => 'tailwind',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Release Token
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This token is stored client-side and sent along with each request to check
|
||||
| a users session to see if a new release has invalidated it. If there is
|
||||
| a mismatch it will throw an error and prompt for a browser refresh.
|
||||
|
|
||||
*/
|
||||
|
||||
'release_token' => 'a',
|
||||
];
|
||||
|
|
@ -35,4 +35,14 @@
|
|||
],
|
||||
],
|
||||
|
||||
'stripe' => [
|
||||
'key' => env('STRIPE_KEY'),
|
||||
'secret' => env('STRIPE_SECRET'),
|
||||
'webhook' => [
|
||||
'secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||
],
|
||||
'price_monthly' => env('STRIPE_PRICE_MONTHLY'),
|
||||
'price_yearly' => env('STRIPE_PRICE_YEARLY'),
|
||||
],
|
||||
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue