Compare commits

..

14 Commits

1627 changed files with 94832 additions and 174 deletions
+4
View File
@@ -34,3 +34,7 @@ yarn-error.log*
!/storage/framework/testing/.gitignore !/storage/framework/testing/.gitignore
!/storage/framework/phpstan/.gitignore !/storage/framework/phpstan/.gitignore
!/storage/logs/.gitignore !/storage/logs/.gitignore
# Subproject ignores
/Project/storage/
/Project/**/*.log
+153
View File
@@ -0,0 +1,153 @@
# Changelog
All notable changes to this project. Format inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and **Semantic Versioning**.
## [Unreleased] — `advanced` branch
### Real-time Dashboard & Custom Widgets (2026-05-16)
#### Added
- **`DashboardStatsUpdated` event** (`app/Events/DashboardStatsUpdated.php`) — implements `ShouldBroadcast`, broadcasts slim stats payload (cpu, ram, disk, users, queues, uptime) to the `admin.monitoring` private channel as `stats.updated`. Replaces the previous `setInterval` polling loop.
- **`BroadcastDashboardStats` artisan command** (`app/Console/Commands/BroadcastDashboardStats.php`) — signature: `dashboard:broadcast-stats`. Clears `monitoring_full_bundle` cache, calls `SystemMonitoringService::getAll()`, dispatches `DashboardStatsUpdated`. Scheduled every minute with `withoutOverlapping()`.
- **Dashboard real-time listener** — `window.Echo.private('admin.monitoring').listen('.stats.updated', applyStats)` in `dashboard.blade.php`. Falls back to `setInterval(refreshStats, 30000)` only when Reverb is not connected.
- **`dashboard_widget_preferences` table** (migration `2026_05_16_220000_create_dashboard_widget_preferences_table.php`) — columns: `user_id` (FK → users, cascade delete), `widget_key` (varchar 64), `visible` (boolean), `sort_order` (smallint). Unique constraint on `(user_id, widget_key)`.
- **`DashboardWidgetPreference` model** (`app/Models/DashboardWidgetPreference.php`) — `forUser(int $userId)` merges static defaults with saved DB prefs, sorted by `sort_order`. `defaults()` defines 7 widgets: `cpu`, `ram`, `disk`, `live_users`, `queues`, `activity_feed`, `ai_insight`.
- **Widget partials** in `resources/views/pages/dashboard/`:
- `widget-cpu.blade.php`, `widget-ram.blade.php`, `widget-disk.blade.php`
- `widget-live-users.blade.php`, `widget-queues.blade.php`
- `widget-quick-actions.blade.php` (quick nav links to common admin pages)
- **Dashboard Customize panel** — floating "Customize" button reveals a slide-down panel with per-widget toggle switches, drag-to-reorder via **SortableJS**, Save Layout button (POSTs to `dashboard.widgets.save`), and Reset to Default button.
- **`DashboardController@saveWidgetPreferences`** — validates and upserts widget prefs (key, visible, sort_order) per user.
- **`DashboardController@resetWidgetPreferences`** — deletes all prefs for authenticated user, restoring defaults.
- **Route** `POST /dashboard/widgets``DashboardController@saveWidgetPreferences` named `dashboard.widgets.save`.
- **SortableJS** loaded via CDN in `dashboard.blade.php` for drag-to-reorder widget grid.
---
### Granular Tab Permission System (2026-05-16)
#### Added
- **85 granular tab-level permissions** covering every settings tab in Global Settings and Mobile Settings panels (e.g., `view global settings branding`, `manage global settings password policy`, `view mobile settings kill switch`).
- **`CheckTabPermission` middleware** — enforces tab-level access control when navigating to specific settings tabs.
- **`@cantab` / `@managetab` Blade directives** — shorthand for checking tab-level permissions in views.
- **Tree-structured UI in role modals** — permission list in Create/Edit Role modals shows permissions grouped in a collapsible tree, making it easier to assign tab-level permissions.
- **Two-panel drag-and-drop permission picker** — role modals have an Available panel and an Assigned panel; items can be dragged between panels or double-clicked to move. Both panels display **category group headers** that appear/disappear dynamically as items move between panels. Multi-select supported.
---
### UI & UX Improvements (2026-05-16)
#### Added
- **Modal border-radius** — `.modal-content` globally styled with `border-radius: 20px` and `overflow: clip` (clip prevents scroll container creation while still clipping visually).
- **Modal width** — `modal-xl` and `modal-permission` modals set to `calc(70vw - 40px)` globally via `app.blade.php`.
#### Fixed
- **Modal scroll broken** — `overflow: hidden` was replaced with `overflow: clip`. `clip` clips visually without creating a new scroll container, so scrollable children inside modals work correctly.
- **Sidebar submenu cannot close** — replaced Alpine.js approach (which was intercepted by the AdminUIUX theme) with vanilla JS `initSidebarSubmenus()` in `navigation.blade.php`. Uses `data-sidebar-toggle` attribute pattern, `e.stopPropagation()`, and `cloneNode()` to prevent duplicate event listeners. Chevron rotates 180° on open via inline style transform, not Alpine transitions.
---
### Tests (+84, total 371 passing) (2026-05-16)
- Added test coverage for tab permission middleware, widget preference CRUD, and real-time broadcast event.
---
### Data Retention Wave (2026-05-15)
#### Added
- **`OtpCode` — Prunable trait**: OTP code yang sudah melewati `expires_at` dipangkas otomatis setiap kali `model:prune` berjalan. Sebelumnya record expired menumpuk tanpa batas.
- **`UserTrustedDevice` — Prunable trait**: Device entry yang sudah melewati `expires_at` dipangkas otomatis. Konsisten dengan OtpCode — keduanya prune berdasarkan kolom `expires_at` yang sudah ada.
- **`AiHealingLog` — Prunable trait, retensi 90 hari**: Log AI self-healing sebelumnya tidak memiliki retention policy. Sekarang dipangkas setelah 90 hari (`created_at`), sejajar dengan `ai_usage_logs` dan `mobile_error_logs`.
- **`PasswordHistory` — Prunable trait, retensi 365 hari**: History password disimpan untuk N-reuse check. Record di luar 365 hari tidak lagi relevan untuk kebijakan reuse dan kini dipangkas otomatis.
- **`telescope:prune --hours=48`** dijadwalkan harian pukul 03:05: Telescope debugging data sebelumnya tumbuh tanpa batas di production. Entry lebih dari 48 jam dipangkas setelah prune model selesai.
#### Summary Retention Matrix (setelah wave ini)
| Model / Table | Retensi | Basis |
|---|---|---|
| `otp_codes` | Setelah expired | `expires_at < now()` |
| `user_trusted_devices` | Setelah expired | `expires_at < now()` |
| `ai_healing_logs` | 90 hari | `created_at` |
| `password_histories` | 365 hari | `created_at` |
| `mobile_error_logs` | 90 hari | `occurred_at` |
| `ai_usage_logs` | 90 hari | `created_at` |
| `mobile_sync_logs` | 30 hari | `synced_at` |
| `notifications` | 30 hari | `created_at` |
| `activity_log` | 365 hari | Spatie config |
| `telescope_entries` | 48 jam | `telescope:prune` |
---
### Added
#### Tests (+287, subtotal 287 passing)
- **Auth security** — `TwoFactorTest` (10), `SocialAuthTest` (9), `ImpersonateTest` (9), `WebAuthnConfigTest` (6).
- **Authorization & gating** — `RoleManagementTest` (13), `PermissionManagementTest` (10), `UserManagementTest` (14), `CheckActivePermissionTest` (4), `IpAccessControlTest` (8), `CheckLegalAgreementTest` (4), `PasswordExpiryMiddlewareTest` (4), `SecurityHeadersTest` (6).
- **Service layer** — `SystemConfigServiceTest` (15), `PasswordPolicyServiceTest` (9), `BackupManagementServiceTest` (7).
- **Pure unit** (no Laravel boot) — `SettingValueCasterTest` (23), `ActivityFormatterTest` (22), `MonitoringFormatterTest` (14), `SessionHelperTest` (12), `PasswordRuleHelperTest` (8), `ApiResponseTest` (10), `CustomExceptionsTest` (4).
- **Database integrity** — `CascadeIntegrityTest` (9) locking FK + soft-delete contracts.
- **Performance** — `NPlusOneTest` (3) regression locks for datatables.
- **Rate limiting** — `RateLimitTest` (6) covering login/register/forgot-password/OTP/2FA + per-IP isolation.
- **API contracts** — `HealthTest` rewrites, `MobileConfigTest` rewrites (ETag + 304).
#### Schema
- `2026_05_12_120000_add_social_columns_to_users_table``google_id`, `facebook_id`, `github_id` columns (previously referenced in `User::$fillable` with no migration).
- `2026_05_14_100000_add_performance_indexes` — composite/secondary indexes:
- `password_histories(user_id, created_at)`
- `system_setting_revisions(key, created_at)` and `(changed_by)`
- `notifications(notifiable_type, notifiable_id, read_at)`
- `2026_05_14_110000_add_fk_to_audit_columns` — FK constraints for `roles.{created_by,updated_by}` and `permissions.{created_by,updated_by}` with `ON DELETE SET NULL`. Orphan ids are nulled before constraint creation.
#### Code
- `App\Services\Monitoring\MonitoringFormatter` — extracted from `SystemMonitoringService`. Methods: `bytes()`, `parseBytes()`, `duration()`.
- `App\Services\SystemConfig\{SettingDefinitions,SettingValueCaster,SettingFileUploader}` — extracted from a 1.272-line god class.
- `App\Exceptions\SystemConfigException` — factories: `unknownKey()`, `imageUploadFailed()`.
- `App\Exceptions\BackupOperationException` — factories: `missingBinary()`, `diskNotConfigured()`, `restoreFailed()`.
- `App\Exceptions\MonitoringException` — factories: `unsupportedOs()`, `probeFailed()`.
#### Tooling & Docs
- **Larastan level 5** with baseline (`phpstan.neon` + `phpstan-baseline.neon`). 82 pre-existing findings are silenced; new code must stay clean.
- **Laravel Pint** applied across the codebase (267 files PSR-12 compliant).
- **CI workflow** (`.github/workflows/ci.yml`) — 3 jobs: `test`, `lint` (pint + composer audit + permissions:audit), `static-analysis` (Larastan).
- **`SECURITY.md`** — vulnerability reporting + supply-chain advisory record + defense-in-depth overview.
- **`CHANGELOG.md`** — this file.
- All major Markdown docs refreshed (`README.md`, `TECH_STACK.md`, `USER_GUIDE.md`, `DEPLOYMENT_GUIDE.md`, `mobile/README.md`).
### Changed
- `SystemConfigService`**1.272 → 197 lines** (-85%). Public API preserved.
- `BackupManagementService` — private `parseBytes`/`formatBytes` delegate to `MonitoringFormatter`. Single canonical implementation.
- `routes/web.php``/auth/callback` now declared before `/auth/{provider}` so OAuth callback is reachable. `{provider}` constrained to `google|facebook|github`.
- `app/Http/Controllers/Api/HealthController` — returns `503` only when at least one check has `status=fail`; `warn` keeps `200` with `status=warn`. Matches OpenAPI annotation.
- `declare(strict_types=1)` on core utility classes (`MonitoringFormatter`, `SettingValueCaster`, `SettingFileUploader`, `ApiResponse`, all new exceptions).
### Fixed
- **`SystemConfigService` stale static cache** — `update()` did not reset `$resolvedSettings`, causing a second update in the same request to compare against pre-update values and silently skip the write. Fixed by always nulling the static in `invalidateCache()`.
- **OAuth callback unreachable** — `/auth/{provider}` ordered before `/auth/callback` caused the wildcard to swallow `/auth/callback` and resolve it as `redirect('callback')`, which 404s on the missing feature flag. Fixed by reordering routes.
- **OAuth identity-overwrite** — callback fell through to `User::create()` on email collision with a different provider id, throwing a unique-violation 500. Fixed by explicitly refusing the link and redirecting to `/login` with a generic error (avoids account enumeration).
- **Missing migration for OAuth columns** — `users.{google_id,facebook_id,github_id}` were in `$fillable` but no migration created them. Added.
- **`HealthController` 503 false-alarm** — `every($checks, status === 'ok')` treated storage `warn` (>90% disk) as unhealthy. Now: only `fail` triggers 503.
- **Pest flake in `UserManagementTest`** — `fake()->name()` could produce strings outside the controller's `/^[a-zA-Z\s]+$/` regex, intermittently failing the update tests under full-suite runs. Hardcoded display names.
### Security
- `SecurityHeaders` middleware (wired globally) — `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `X-XSS-Protection`, HSTS (HTTPS + opt-in).
- Test coverage for the entire auth boundary: 2FA, Impersonate, SocialAuth (incl. identity-overwrite protection), 4 middleware (`IpAccessControl`, `CheckActivePermission`, `PasswordExpiry`, `CheckLegalAgreement`).
- Rate-limit regression tests prevent silent throttle removal.
### Known Follow-ups
- `SystemMonitoringService` (~600 lines, `shell_exec`/`disk_*`/`sys_getloadavg`) still tightly coupled to OS calls. Refactor target: extract `SystemInfoProvider` interface + `LinuxSystemInfoProvider`/`WindowsSystemInfoProvider`/`FakeSystemInfoProvider` so the service can be unit-tested without mocking globals. Deferred to a dedicated change.
- `laragear/webauthn` is marked **abandoned** upstream. Replacement: `laravel/passkeys`. Tracked separately because the public surface differs.
- Mobile `expo`/`@expo/cli`/`@expo/metro-config`/`postcss` chain has **4 moderate** CVEs reachable only in dev-build tooling. Fix requires Expo SDK bump (breaking change).
+478
View File
@@ -0,0 +1,478 @@
# Deployment Guide
Panduan instalasi aplikasi di server produksi **Ubuntu 22.04+**.
---
## Spesifikasi Server
| Komponen | Minimum | Rekomendasi |
|----------|---------|-------------|
| CPU | 2 core | 4 core |
| RAM | 4 GB | 8 GB |
| Storage | 40 GB SSD | 100 GB SSD |
| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
---
## Langkah 1 — Install Dependensi
```bash
sudo apt update && sudo apt upgrade -y
# PHP 8.3 + ekstensi
sudo add-apt-repository ppa:ondrej/php -y
sudo apt install -y php8.3 php8.3-fpm php8.3-pgsql php8.3-redis \
php8.3-mbstring php8.3-xml php8.3-curl php8.3-zip php8.3-gd \
php8.3-bcmath php8.3-intl php8.3-readline
# PostgreSQL, Redis, Nginx, Supervisor
sudo apt install -y postgresql postgresql-contrib redis-server nginx supervisor
sudo systemctl enable redis-server
# Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
```
---
## Langkah 2 — Setup Database
```bash
sudo -u postgres psql
```
```sql
CREATE USER biiproject WITH PASSWORD 'password_kuat_anda';
CREATE DATABASE biiproject_db OWNER biiproject;
GRANT ALL PRIVILEGES ON DATABASE biiproject_db TO biiproject;
\q
```
---
## Langkah 3 — Deploy Aplikasi
```bash
cd /var/www
sudo git clone <repo-url> html
sudo chown -R $USER:www-data html
cd html
composer install --no-dev --optimize-autoloader
npm install && npm run build
cp .env.example .env
php artisan key:generate
```
### Edit `.env`
```env
APP_NAME="biiproject"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://domain.com
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=biiproject_db
DB_USERNAME=biiproject
DB_PASSWORD=password_kuat_anda
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
BROADCAST_CONNECTION=reverb
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REVERB_APP_ID=your-app-id
REVERB_APP_KEY=your-app-key
REVERB_APP_SECRET=your-app-secret
REVERB_HOST=domain.com
REVERB_PORT=8080
REVERB_SCHEME=https
BCRYPT_ROUNDS=12
# Opsional — Error monitoring
SENTRY_LARAVEL_DSN=https://xxxx@sentry.io/xxxx
SENTRY_TRACES_SAMPLE_RATE=0.1
SENTRY_PROFILES_SAMPLE_RATE=0.1
```
### Migrasi, Seed & Optimisasi
```bash
php artisan migrate --force
php artisan db:seed --force
php artisan cache:clear # wajib setelah seeder
php artisan storage:link
php artisan optimize
php artisan l5-swagger:generate # regen API docs
```
### Post-Deploy Verification
```bash
# Quality gate — pastikan tidak ada regresi
./vendor/bin/phpstan analyse --memory-limit=1G
./vendor/bin/pint --test
composer audit --no-dev
# Health check
curl -sf https://domain.com/api/health | jq .
# expect: {"status":"healthy","timestamp":"...","checks":{...}}
```
---
## Langkah 4 — Konfigurasi Nginx
`/etc/nginx/sites-available/biiproject`:
```nginx
server {
listen 80;
server_name domain.com;
root /var/www/html/public;
index index.php;
client_max_body_size 50M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
# WebSocket Reverb
location /app {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 60s;
}
location ~ /\.(?!well-known) { deny all; }
}
```
> **Catatan keamanan:** Laravel mengirim header `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, dan `Permissions-Policy` dari middleware `SecurityHeaders`. Tidak perlu duplikasi di Nginx — biarkan aplikasi yang kontrol (lebih mudah di-update via setting).
```bash
sudo ln -s /etc/nginx/sites-available/biiproject /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
---
## Langkah 5 — SSL (Let's Encrypt)
```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d domain.com
```
Setelah HTTPS aktif, **aktifkan HSTS** dari admin panel: Global Settings → Login Security → `HSTS Enabled`. Aplikasi akan mengirim header `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`.
---
## Langkah 6 — Background Workers (Supervisor)
### Queue Worker
`/etc/supervisor/conf.d/biiproject-worker.conf`:
```ini
[program:biiproject-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker.log
```
### Reverb (WebSocket)
`/etc/supervisor/conf.d/biiproject-reverb.conf`:
```ini
[program:biiproject-reverb]
command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/reverb.log
```
### Scheduler (Cron)
```bash
echo "* * * * * cd /var/www/html && php artisan schedule:run >> /dev/null 2>&1" \
| sudo crontab -u www-data -
```
> Scheduler menjalankan `dashboard:broadcast-stats` setiap menit — membutuhkan queue worker dan Reverb **sudah berjalan** agar broadcast terkirim ke browser. Pastikan keduanya distart via Supervisor sebelum mengaktifkan cron.
### Aktifkan
```bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start all
```
---
## Langkah 7 — Permission File
```bash
sudo chown -R www-data:www-data /var/www/html
sudo chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache
```
---
## Langkah 8 — Konfigurasi Pasca-Deploy
### A. Global Settings
Login sebagai Super Admin → System Settings → Global Settings. Pastikan:
- [ ] `Password Policy`: min 12, charset mixed-case + digit + symbol, expiry & history sesuai kebijakan
- [ ] `Login Security`: max attempts 5, lockout duration, 2FA toggle, captcha jika perlu
- [ ] `IP & Access`: whitelist admin (IP kantor/VPN), auto-block on burst
- [ ] `HSTS Enabled`: ON (setelah SSL aktif)
- [ ] `Single Session`: ON jika kebijakan satu-device-per-akun
### B. Verifikasi Quality Gate
```bash
# Pastikan dependencies aman
composer audit --no-dev --abandoned=ignore
# Pastikan tidak ada regresi
php artisan test --parallel
```
---
## Layanan Eksternal (Opsional)
Sebagian besar bisa diatur via **Global Settings** di admin panel — tidak perlu edit `.env`.
### Email SMTP
```env
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=postmaster@domain.com
MAIL_PASSWORD=xxxxxxxx
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@domain.com
MAIL_FROM_NAME="biiproject"
```
### Sentry (Error Monitoring)
```env
SENTRY_LARAVEL_DSN=https://xxxx@o0.ingest.sentry.io/xxxx
SENTRY_TRACES_SAMPLE_RATE=0.1
```
Buat project di [sentry.io](https://sentry.io), pilih platform **Laravel**, dan salin DSN-nya.
### Telegram Bot
1. Chat `@BotFather``/newbot` → simpan **Token**
2. Kirim pesan ke bot, buka `https://api.telegram.org/bot<TOKEN>/getUpdates` → simpan **Chat ID**
3. Masukkan via Global Settings → Notifications
> **Catatan:** `IpAccessControl` middleware mengirim alert otomatis ke Telegram saat sebuah IP di-auto-block karena burst — pastikan token + chat id valid.
### Google Drive Backup
1. Google Cloud Console → buat project → enable **Google Drive API**
2. Buat **OAuth 2.0 Client ID** (Desktop app)
3. Dapatkan **Refresh Token** via OAuth Playground
4. Masukkan Client ID, Client Secret, Refresh Token, dan nama folder via Global Settings → Backup
### Amazon S3
1. Buat IAM user dengan policy `AmazonS3FullAccess`
2. Buat S3 bucket
3. Masukkan Access Key, Secret, Bucket, Region (dan endpoint opsional) via Global Settings → Backup
### Firebase Cloud Messaging (Push Notification Mobile)
1. Buat project di Firebase Console → Project Settings → Cloud Messaging
2. Unduh `google-services.json` (Android) atau `GoogleService-Info.plist` (iOS)
3. Letakkan di direktori mobile app sesuai panduan Expo
4. Device token diregistrasikan otomatis via API `/api/v1/devices/register`
---
## CI/CD
Workflow di `.github/workflows/ci.yml`. Setiap push ke `main`/`develop`/`config`/`advanced` dan setiap PR ke `main`/`develop` menjalankan:
1. **Test**`php artisan test --parallel` (Postgres 15 + Redis 7 service containers)
2. **Lint**`pint --test` + `composer audit --abandoned=ignore` + `permissions:audit`
3. **Static Analysis**`phpstan analyse --memory-limit=1G`
Branch protection: PR tidak bisa di-merge kalau salah satu job gagal.
---
## Update Aplikasi
Cara tercepat dan teraman untuk memperbarui aplikasi adalah menggunakan skrip deployment otomatis yang sudah disediakan:
```bash
cd /var/www/html
sudo chmod +x deploy.sh
./deploy.sh
```
Skrip ini akan melakukan:
1. Masuk ke Maintenance Mode.
2. Update dependencies (Composer & NPM).
3. Build assets (Vite).
4. Run migrations.
5. Optimasi cache & pembersihan log.
6. Restart background workers (Queue & Reverb).
7. Verifikasi kesehatan sistem.
8. Keluar dari Maintenance Mode.
### Update Manual
```bash
cd /var/www/html
git pull origin main
php artisan down --secret="your-bypass-key"
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
php artisan cache:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan l5-swagger:generate
sudo supervisorctl restart biiproject-worker:*
sudo supervisorctl restart biiproject-reverb
# Verifikasi
curl -sf https://domain.com/api/health | jq -e '.checks.database.status == "ok"'
php artisan up
```
---
## Deployment via Docker (alternatif)
```bash
git clone <repo-url> Project
cd Project
cp .env.example .env
# Edit .env:
# DB_HOST=pgsql (nama service Docker, bukan 127.0.0.1)
# REDIS_HOST=redis
./vendor/bin/sail up -d
./vendor/bin/sail artisan migrate --seed --force
./vendor/bin/sail artisan cache:clear
./vendor/bin/sail artisan optimize
```
> **Penting:** Semua perintah `php artisan` di lingkungan Docker **harus** dijalankan via `./vendor/bin/sail artisan`, bukan langsung. Host `pgsql` hanya bisa di-resolve dari dalam Docker network.
---
## Troubleshooting
| Masalah | Solusi |
|---------|--------|
| 502 Bad Gateway | `sudo systemctl status php8.3-fpm` |
| Queue tidak jalan | `sudo supervisorctl status` |
| Permission denied | `sudo chown -R www-data:www-data storage bootstrap/cache` |
| Cache error setelah update | `php artisan optimize:clear && php artisan optimize` |
| Settings tidak muncul setelah seeder | `php artisan cache:clear` (settings di-cache 60 menit) |
| WebSocket gagal connect | `sudo supervisorctl status biiproject-reverb` + cek port 8080 di firewall |
| `pgsql` host tidak resolve | Pastikan `DB_HOST=127.0.0.1` (bukan `pgsql`) di server non-Docker |
| Telescope/log tidak ditemukan | Cek `storage/logs/laravel.log` & menu System Monitoring → Logs |
| Error monitoring tidak aktif | Pastikan `SENTRY_LARAVEL_DSN` sudah diset di `.env` |
| `/api/health` return 503 | Cek setiap field `checks.*.status` — yang `fail` mengindikasikan masalah. `warn` (storage >90%) tetap 200 by design. |
| OAuth callback 404 | Pastikan route order: `/auth/callback` HARUS sebelum `/auth/{provider}` di `routes/web.php`. |
| `column "google_id" does not exist` | Pastikan migrasi `2026_05_12_120000_add_social_columns_to_users_table` sudah jalan. |
| Dashboard stats tidak update real-time | Cek Reverb berjalan (`supervisorctl status biiproject-reverb`), queue worker aktif, dan `BROADCAST_CONNECTION=reverb` di `.env`. |
| Dashboard widget tidak tersimpan | Pastikan migrasi `2026_05_16_220000_create_dashboard_widget_preferences_table` sudah dijalankan. |
---
## Performance Tuning
### PHP-FPM
`/etc/php/8.3/fpm/pool.d/www.conf`:
```ini
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
```
### OPcache
`/etc/php/8.3/fpm/php.ini`:
```ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; production only
opcache.preload=/var/www/html/bootstrap/cache/preload.php
opcache.preload_user=www-data
```
### Redis Tuning
`/etc/redis/redis.conf`:
```
maxmemory 1gb
maxmemory-policy allkeys-lru
```
### PostgreSQL
Tambah index recommendation: sudah ada (lihat migrasi `2026_05_14_100000_add_performance_indexes`). Untuk dataset besar (>10M rows pada `activity_log`), pertimbangkan `pg_partman` untuk partisi bulanan.
+173 -163
View File
@@ -1,223 +1,233 @@
# biiproject # biiproject-kit v1
Aplikasi web manajemen bisnis berbasis **Laravel 13** dengan PostgreSQL, Redis, dan WebSocket real-time. [![Laravel](https://img.shields.io/badge/Laravel-13.x-FF2D20?style=for-the-badge&logo=laravel)](https://laravel.com)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15-4169E1?style=for-the-badge&logo=postgresql)](https://www.postgresql.org)
[![Redis](https://img.shields.io/badge/Redis-Alpine-DC382D?style=for-the-badge&logo=redis)](https://redis.io)
[![Tests](https://img.shields.io/badge/Tests-371%20Passed-31C653?style=for-the-badge)]()
[![Pint](https://img.shields.io/badge/Pint-Clean-007ACC?style=for-the-badge)]()
[![Larastan](https://img.shields.io/badge/Larastan-Level%205-blue?style=for-the-badge)]()
[![Tests](https://img.shields.io/badge/tests-371%20passed-brightgreen)]() [![Larastan](https://img.shields.io/badge/Larastan-level%205-blue)]() [![Pint](https://img.shields.io/badge/Pint-clean-blue)]() [![License](https://img.shields.io/badge/license-Proprietary-red)]() A high-performance, secure, and enterprise-ready **Laravel 13** starter kit featuring a comprehensive real-time admin monitoring dashboard, a granular Spatie permission matrix with Blade templates, custom backup services, and ready-to-use Expo React Native mobile application API integration. **Version 1** is designed to provide a highly optimized and rock-solid foundation for business management and SaaS systems.
--- ---
## Fitur Utama ## 🚀 Key Architectural Features in v1
- **Dashboard Admin Real-time** — ringkasan CPU/RAM/Disk/Live Users/Queue dengan update via WebSocket (Reverb). Widget bisa disembunyikan, diurutkan ulang (drag), dan disimpan per-user. Fallback ke polling 30 detik jika Reverb tidak terhubung. * 📊 **Real-time Admin Monitoring**Dynamic telemetry panel tracking CPU, RAM, Disk usage, and live active users powered by Laravel Reverb WebSockets. Configurable drag-and-drop widget layout is saved per user.
- **Custom Dashboard Widgets** — 7 widget bawaan (cpu, ram, disk, live users, queues, activity feed, AI insight). Per-user layout tersimpan di `dashboard_widget_preferences`. Toggle show/hide + drag-to-reorder via SortableJS. * 🛡️ **Granular Tab-Level Access**Highly custom authorization gates mapping 85 permission levels for Global Settings and Mobile Remote variables using Blade directives (`@cantab` and `@managetab`).
- **Manajemen Pengguna** — role & permission granular (Spatie), soft delete + restore + force delete, bulk action * ⚙️ **Integrated Control Console**Unified administration backend governing application branding details, live SMTP servers, OAuth login triggers, automated backups, and maintenance gates.
- **Global Settings** — branding, keamanan, email, AI, SAP, backup, dan lainnya dalam satu panel * 💾 **Secure Backup Automation** &mdash; Integrated scheduling mechanisms routing encrypted backups to Cloud storage (Amazon S3 or Google Drive) with custom integrity verification.
- **Mobile Settings** — kontrol remote konfigurasi aplikasi Android/iOS * 🤖 **AI Intelligence Engine**Direct adapters for OpenAI, Gemini, and Mistral, providing automatic Swagger annotations, system diagnostic logs auditing, and real-time security score assessments.
- **Maintenance Mode** — offline page dengan countdown, bypass key, dan IP whitelist * 📱 **Expo Mobile Application integration**Native Sanctum API token exchange, dynamic configuration sync, and device token registration endpoints ready for Push Notifications.
- **Backup & Restore** — Local, Amazon S3, atau Google Drive dengan enkripsi opsional
- **System Monitoring** — log Laravel, log SAP, log mobile, background job, AI usage, health check
- **Notifikasi Real-time** — WebSocket via Laravel Reverb + Notification Center. Dashboard stats di-push tiap menit via `dashboard:broadcast-stats`.
- **Granular Tab Permissions** — 85 permission level tab untuk Global/Mobile Settings. `CheckTabPermission` middleware + `@cantab`/`@managetab` Blade directives. Picker role dengan UI two-panel drag-drop dan category headers.
- **Session Manager** — lihat & paksa logout sesi aktif, single-session enforcement opsional
- **Legal & Content** — Privacy Policy, ToS, About (WYSIWYG), kepatuhan UU PDP No. 27/2022
- **Mobile App** — React Native + Expo dengan API Sanctum, OTP, device token (push notification)
- **Audit Trail** — semua perubahan tercatat via Spatie ActivityLog + Action Log
- **Error Monitoring** — Sentry integration untuk production error tracking
- **Passkeys (WebAuthn)** — login biometrik/FIDO2
- **Social OAuth** — Google, Facebook, GitHub (callback aman terhadap identity-overwrite)
- **AI Intelligence Engine** — Integrasi OpenAI, Gemini, Claude, DeepSeek, Mistral, dll.
- **Smart Search (CMD+K)** — Navigasi cerdas & AI Assistant terintegrasi
- **AI Security Audit** — Skor keamanan otomatis & rekomendasi perkuatan (hardening)
- **AI Error Diagnostics** — Analisis otomatis & saran perbaikan saat terjadi error sistem
- **API Documentation** — Swagger/OpenAPI otomatis (l5-swagger) dengan bantuan AI
--- ---
## Keamanan Bawaan ## 🛠️ Tech Stack & Dependencies
- **Security headers**: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `X-XSS-Protection`, dan `Strict-Transport-Security` (HTTPS) di-set otomatis oleh middleware global. | Layer | Technology | Version | Description |
- **Rate limiting**: throttle pada `/login`, `/2fa`, `/forgot-password`, `/api/v1/otp/*`, dan endpoint mobile lain. Per-IP bucket terisolasi. |---|---|---|---|
- **Password policy**: panjang min/max, charset wajib, expiry, dan **history reuse blocker** (Bcrypt 12 rounds). | **Core Framework** | Laravel | `13.x` | Modern backend routing, scheduler, and service container |
- **IP access control**: whitelist admin, blacklist global, auto-block on burst (24 jam) dengan alert Telegram. | **Database Engine** | PostgreSQL | `15.x` | Relational database storage |
- **Data integrity**: FK constraint penuh di semua tabel audit; soft-delete cascade tested. | **Caching & Queue** | Redis | `Alpine` | High-speed cache memory and asynchronous queues |
- **Data retention otomatis**: 10 tabel/model memiliki kebijakan retensi — OTP & trusted device dipangkas saat expired, log AI & healing 90 hari, password history 365 hari, Telescope 48 jam. Dijalankan via `model:prune` + `telescope:prune` setiap dini hari. | **Real-time Server**| Laravel Reverb | `1.x` | Native high-performance WebSockets broadcaster |
| **Frontend UI** | Blade + SortableJS | `v1.x` | Server-side templating with interactive drag-drop widgets |
| **Authentication** | Breeze + WebAuthn | `v2.x` | Classic web sessions + FIDO2 Biometric Passkeys |
| **Roles & Privileges** | Spatie Permissions | `v6.x` | Granular permission layers mapped to Blade templates |
| **Audit Trail** | Spatie Activity Logs| `v4.x` | Transparent logging for models and user actions |
| **Docs Generator** | Swagger (L5-Swagger) | `v8.x` | OpenAPI spec files with integrated AI assistant |
--- ---
## Quality Gate ## 📂 Directory Structure Overview
| Check | Status | Tool | This project follows strict clean code practices and Laravel standard modular architectures:
|-------|--------|------|
| Unit & feature tests | **371 / 371 ✓** | Pest 4 |
| Static analysis | **clean** | Larastan level 5 (baseline) |
| Code style | **clean** | Laravel Pint (PSR-12) |
| Dependency audit | **0 vulns** | `composer audit` |
| N+1 regression locks | **3 datatables** | Pest + Query Log |
CI menjalankan keempatnya di setiap push/PR — lihat [`.github/workflows/ci.yml`](.github/workflows/ci.yml). ```text
├── app/
```bash │ ├── Exceptions/ # SystemConfig/Backup/Monitoring exception classes
./vendor/bin/sail artisan test │ ├── Helpers/ # SettingsHelper, SessionHelper, ImpersonateHelper, PasswordRuleHelper
./vendor/bin/sail bin phpstan analyse │ ├── Http/
./vendor/bin/sail bin pint --test │ │ ├── Controllers/ # AccessControl, Auth, SystemSettings, WebAuthn, Dashboard modules
./vendor/bin/sail composer audit │ │ ├── Helpers/ # Standardized JSON API responses formats
│ │ └── Middleware/ # SecurityHeaders, IpAccessControl, CheckActivePermission, Gzip
│ ├── Models/ # Primary Eloquent schemas (User, OtpCode, PasswordHistory, DeviceToken)
│ └── Services/ # AI Service adapters, Backup management, SystemConfig caches
├── config/ # Consolidated application parameters
├── database/
│ ├── migrations/ # Database schemas (40+ migrations)
│ └── seeders/ # Dynamic settings, mobile variables, and primary RBAC matrix
├── docker/ # Standardized Sail multi-service docker compose environments
├── public/ # Standard assets (vendor scripts, custom CSS)
├── resources/
│ └── views/ # Server-side Blade layouts, templates, and view components
├── routes/ # Divided routing protocols (web, api, auth, ai, channels, console)
└── tests/ # 371 feature-rich Pest integration tests
``` ```
--- ---
## Perintah Artisan Khusus ## ⚡ Quick Start & Development
Sistem ini dilengkapi dengan perintah CLI tambahan untuk memudahkan administrasi: Get your development environment up and running quickly:
| Perintah | Deskripsi | ### Manual Setup (Without Docker)
|----------|-----------|
| `php artisan system:check` | Audit kesehatan infrastruktur (DB, Redis, Storage, AI). |
| `php artisan system:optimize` | Optimasi cache & pembersihan log produksi. |
| `php artisan ai:swagger {path}` | Menghasilkan anotasi Swagger otomatis menggunakan AI. |
| `php artisan system:send-digest` | Mengirim ringkasan kesehatan sistem mingguan ke Admin. |
| `php artisan backups:verify` | Verifikasi integritas file cadangan di cloud/lokal. |
| `php artisan l5-swagger:generate` | Regenerasi dokumentasi API OpenAPI. |
| `php artisan model:prune` | Pangkas data kedaluwarsa (OTP, trusted device, AI log, password history, dll). |
| `php artisan telescope:prune --hours=48` | Hapus Telescope entries lebih dari 48 jam. |
| `php artisan dashboard:broadcast-stats` | Broadcast statistik sistem terbaru ke channel WebSocket `admin.monitoring`. Dijadwalkan tiap menit. |
---
## Mulai Cepat (Development)
### Tanpa Docker
1. **Clone & Install Dependencies:**
```bash ```bash
# 1. Clone & install
git clone <repo-url> Project && cd Project git clone <repo-url> Project && cd Project
composer install composer install
npm install npm install
```
# 2. Environment 2. **Setup Environment Configuration:**
```bash
cp .env.example .env cp .env.example .env
# Edit .env: DB_HOST=127.0.0.1, REDIS_HOST=127.0.0.1 # Configure your DB_HOST=127.0.0.1 and REDIS_HOST=127.0.0.1 in .env
php artisan key:generate php artisan key:generate
```
# 3. Database & seed 3. **Run Migrations & Seeds:**
```bash
php artisan migrate --seed php artisan migrate --seed
```
# 4. Jalankan (server + vite + reverb + queue + scheduler) 4. **Launch Development Servers:**
```bash
composer run dev composer run dev
``` ```
### Via Docker (Laravel Sail) — Direkomendasikan ---
### 🔧 Containerized Setup (Laravel Sail) — Recommended
If you prefer using Docker:
1. **Spin Up Containers:**
```bash ```bash
./vendor/bin/sail up -d ./vendor/bin/sail up -d
```
2. **Initialize Database:**
```bash
./vendor/bin/sail artisan migrate --seed ./vendor/bin/sail artisan migrate --seed
``` ```
Aplikasi dapat diakses di `http://localhost:8000`. The application will be accessible immediately at `http://localhost:8000`.
> **Penting:** Jika seeder dijalankan, selalu clear cache setelahnya agar perubahan muncul di aplikasi: > [!TIP]
> Always clear application cache after seeding is completed to reflect settings instantly:
> ```bash > ```bash
> ./vendor/bin/sail artisan cache:clear > ./vendor/bin/sail artisan cache:clear
> ``` > ```
### Menjalankan Test Suite ---
```bash ## 🔐 Default Credentials
./vendor/bin/sail artisan test # 371 tests (full)
./vendor/bin/sail artisan test --filter Auth # filter Use the default credentials below to test the RBAC capabilities of the starter kit:
./vendor/bin/sail bin phpstan analyse # static analysis
./vendor/bin/sail bin pint --test # code style check | Role | Email | Password | Role Description |
./vendor/bin/sail bin pint # code style auto-fix |---|---|---|---|
``` | **Super Admin** | `superadmin@biiproject.com` | `password` | Unrestricted access. Bypasses all system gates. |
| **Admin** | `admin@biiproject.com` | `password` | Manager privileges for access control, logs, and settings. |
| **User** | `user@biiproject.com` | `password` | Standard user role with read-only dashboard layout. |
> [!IMPORTANT]
> Please change default passwords immediately after deployment. Bcrypt 12 rounds + history blockers are active by default.
--- ---
## Akun Default (setelah seed) ## 🛡️ Built-in Security Policies
| Role | Email | Password | * **Security Headers** — Automatically injected custom headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `X-XSS-Protection`, `Strict-Transport-Security`) protecting all routing responses.
|------|-------|----------| * **Smart Rate Limiting** — Intelligent throttle thresholds applied on `/login`, `/2fa`, `/forgot-password`, `/api/v1/otp/*`, and Expo client login gates.
| Super Admin | superadmin@biiproject.com | password | * **Robust Password Policy** — Dynamic complexity regulations (minimum length, mixed-case, numbers, special characters) with Bcrypt 12 rounds encryption and **365-day history reuse blocker**.
| Admin | admin@biiproject.com | password | * **IP Access Control** — Customizable administrator Whitelists, global blacklists, and automated burst-block (24 hours) trigger alerting via Telegram.
| User | user@biiproject.com | password | * **Auto Data Retention** — Dynamic automated pruning pipelines running daily via `model:prune` (expired OTPs/trusted devices, 90-day AI history logs, 48-hour Telescope database entries).
> Ganti password segera setelah deploy. Bcrypt 12 rounds + history block aktif by default.
--- ---
## Dokumentasi ## ⚡ Quality Gate Standards
| Dokumen | Untuk Siapa | Isi | All components are rigorously audited under continuous quality benchmarks:
|---------|-------------|-----|
| [README.md](README.md) | Semua | Ringkasan & quick start (file ini) | | Benchmark | Standard | Auditing Tool |
| [USER_GUIDE.md](USER_GUIDE.md) | Admin / Operator | Cara pakai panel admin | |---|---|---|
| [TECH_STACK.md](TECH_STACK.md) | Developer | Framework, library, plugin, tooling, CI | | **Unit & Feature Tests** | `371 / 371 Passed` | Pest 4 / PHPUnit |
| [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) | DevOps | Instalasi server produksi | | **Static Code Analysis** | `Clean` | Larastan (Level 5 Baseline) |
| [SECURITY.md](SECURITY.md) | All | Reporting & supply-chain advisory | | **Code Style Conformity**| `Clean` | Laravel Pint (PSR-12 ruleset) |
| [CHANGELOG.md](CHANGELOG.md) | All | Log perubahan | | **Dependency Security** | `0 Vulnerabilities` | `composer audit` |
| [mobile/README.md](mobile/README.md) | Mobile Dev | Build & pengembangan aplikasi Android/iOS | | **Query Performance** | `0 N+1 Regressions` | Pest + Custom Query Logger |
--- ---
## Struktur Direktori ## 🔌 API Endpoints Reference (v1)
``` All endpoints are versioned and situated under `/api/v1/*`. Requests requesting authorization require an HTTP header formatted as `Authorization: Bearer <your_token>`.
Project/
├── app/ ### Authentication & Config
│ ├── Exceptions/ SystemConfig/Backup/Monitoring exception classes | Method | Endpoint | Auth | Description |
│ ├── Helpers/ SettingsHelper, SessionHelper, ImpersonateHelper, PasswordRuleHelper |---|---|---|---|
│ ├── Http/ | `POST` | `/api/v1/login` | — | Exchange credentials for Bearer Token (Rate limited) |
│ │ ├── Controllers/ | `POST` | `/api/v1/register` | — | Register a new user account (Rate limited) |
│ │ │ ├── AccessControl/ User, Role, Permission, ActionLog management | `POST` | `/api/v1/forgot-password`| — | Request reset password link |
│ │ │ ├── Admin/ Mobile settings | `GET` | `/api/v1/app-config` | — | Retrieve mobile app remote configuration parameters |
│ │ │ ├── Api/ Sanctum-protected mobile API (v1) + Health | `GET` | `/api/v1/mobile/sync` | — | Sync latest configurations and updates |
│ │ │ ├── Auth/ Login, 2FA, Passkey (WebAuthn), Social OAuth | `POST` | `/api/v1/mobile/log` | — | Send mobile application logs to server (Rate limited) |
│ │ │ ├── SystemSettings/ Global settings, monitoring, backup, maintenance
│ │ │ ├── WebAuthn/ Laragear WebAuthn login/register controllers ### OTP Gateway
│ │ │ ├── DashboardController.php | Method | Endpoint | Auth | Description |
│ │ │ ├── ImpersonateController.php |---|---|---|---|
│ │ │ ├── LegalController.php | `POST` | `/api/v1/otp/send` | — | Request verification OTP code via Email/WhatsApp (Rate limited) |
│ │ │ └── ProfileController.php | `POST` | `/api/v1/otp/verify` | — | Validate the OTP code |
│ │ ├── Helpers/ ApiResponse
│ │ └── Middleware/ SecurityHeaders, IpAccessControl, CheckActivePermission, ### Profile & Dashboard (Authenticated)
│ │ CheckLegalAgreement, PasswordExpiry, GzipCompression | Method | Endpoint | Auth | Description |
│ ├── Services/ |---|---|---|---|
│ │ ├── Auth/ PasswordPolicyService | `GET` | `/api/v1/user` | Bearer | Fetch authenticated user data, roles, and permissions |
│ │ ├── AI/ Multi-provider AI service abstraction | `POST` | `/api/v1/logout` | Bearer | Revoke current authenticated session token |
│ │ ├── MobileConfig/ MobileConfigService (admin → mobile sync) | `POST` | `/api/v1/profile/update` | Bearer | Update user profile personal details |
│ │ ├── Monitoring/ SystemMonitoringService + MonitoringFormatter | `POST` | `/api/v1/profile/avatar` | Bearer | Upload and update profile photo |
│ │ ├── Notification/ FCM, Telegram adapters | `POST` | `/api/v1/profile/password` | Bearer | Change account login password |
│ │ ├── System/ BackupManagementService, MaintenanceManagementService, | `DELETE` | `/api/v1/profile/delete` | Bearer | Self account termination/deletion |
│ │ │ ActivityFormatter, GlobalSearchService | `GET` | `/api/v1/dashboard` | Bearer | Retrieve secure mobile dashboard analytics |
│ │ └── SystemConfig/ SystemConfigService + SettingDefinitions +
│ │ SettingValueCaster + SettingFileUploader ### Push Notification Registry
│ └── Models/ User, Role, Permission, SystemSetting (+ Revision), | Method | Endpoint | Auth | Description |
│ MobileSetting, OtpCode, PasswordHistory, DeviceToken, |---|---|---|---|
│ DashboardWidgetPreference, ... | `POST` | `/api/v1/devices/register` | Bearer | Register target FCM device token |
├── config/ Konfigurasi Laravel | `DELETE`| `/api/v1/devices/unregister`| Bearer | Revoke and unregister FCM device token |
├── database/
│ ├── migrations/ Skema database (40+ tabel)
│ └── seeders/ RoleAndPermission, SystemSetting, MobileSetting, AdminUser
├── docker/ Konfigurasi Sail (PHP, Postgres, Redis)
├── mobile/ Aplikasi React Native (Expo SDK 54+)
├── resources/views/ Template Blade
├── routes/
│ ├── web.php Rute web (admin panel)
│ ├── api.php Rute API mobile (prefix /api/v1)
│ ├── auth.php Rute autentikasi Breeze + 2FA + WebAuthn
│ ├── ai.php Endpoint AI assistant
│ ├── channels.php Broadcast channel auth
│ └── console.php Schedule kernel
├── storage/api-docs/ Generated OpenAPI/Swagger spec
├── storage/logs/ File log aplikasi
├── tests/
│ ├── Feature/ HTTP + integration tests
│ └── Unit/ Pure logic (Formatter, Caster, Helpers, Exceptions)
├── phpstan.neon Larastan config (level 5)
├── phpstan-baseline.neon Pre-existing errors silenced
└── .github/workflows/ci.yml Test + Lint + Static Analysis pipeline
```
--- ---
## Lisensi ## 🛠️ Specialized Artisan Commands
Proprietary © 2026 Andika Debi Putra. Lihat header tiap file. Dirancang dengan kepatuhan terhadap **UU PDP No. 27/2022**. The administration console provides customized CLI commands for operational workflows:
| Command | Description |
|---|---|
| `php artisan system:check` | Audit core infrastructure health (Database, Redis, Cloud Storage, AI engines). |
| `php artisan system:optimize` | Consolidate caches and wipe out production application logs. |
| `php artisan ai:swagger {path}` | Generate automated Swagger controller annotations utilizing OpenAI. |
| `php artisan system:send-digest` | Dispatch weekly operational system health digest to Administrators. |
| `php artisan backups:verify` | Audit and verify the integrity of local/cloud backup files. |
| `php artisan l5-swagger:generate` | Compile and regenerate OpenAPI/Swagger specifications. |
| `php artisan model:prune` | Safely clear out expired OTP keys, passwords histories, and expired device records. |
| `php artisan telescope:prune --hours=48`| Clear out Telescope registry entries older than 48 hours. |
| `php artisan dashboard:broadcast-stats`| Broadcast updated CPU/RAM/Disk stats to the admin monitoring channel. Scheduled minutely. |
---
## 📖 Related Manuals
| Document | Target Audience | Content |
|---|---|---|
| [README.md](README.md) | All Users | Quick Start & Architectural Overview (This file) |
| [USER_GUIDE.md](USER_GUIDE.md) | Administrators | Operational guidelines for the administrative panel |
| [TECH_STACK.md](TECH_STACK.md) | Developers | Architectural dependencies, CI pipelines, and plugins details |
| [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) | DevOps Engineers | Outlines production environment server deployments |
| [SECURITY.md](SECURITY.md) | All Users | Security policies and reporting protocols |
| [CHANGELOG.md](CHANGELOG.md) | All Users | Versioned repository changes log |
| [mobile/README.md](mobile/README.md) | Mobile Engineers | Outline and instructions for React Native/Expo builds |
---
## 📄 License & Terms
Proprietary © 2026 Andika Debi Putra (Debesocial). Designed and packaged to expedite development while aligning with modern security and architectural guidelines (Compliant with **UU PDP No. 27/2022**). All rights reserved.
+127
View File
@@ -0,0 +1,127 @@
# Security
## Reporting
Report vulnerabilities to **andikadebiputra@gmail.com**. Please do not open public issues for security problems.
We aim to acknowledge within 48 hours and to ship a fix or mitigation for critical issues within 7 days.
---
## Supply Chain — Known Advisories (as of 2026-05-15)
### Backend (`composer audit`)
- **No vulnerability advisories.**
- `laragear/webauthn` is marked **abandoned**. Replacement: `laravel/passkeys`. Migration planned for a separate change since the public surface differs.
### Frontend root (`npm audit`)
- **No vulnerabilities.**
### Mobile (`mobile/`, `npm audit`)
- **4 moderate** — `postcss < 8.5.10` ([GHSA-qx2v-qp2m-jg93](https://github.com/advisories/GHSA-qx2v-qp2m-jg93), XSS via unescaped `</style>` in CSS output).
- Path: transitively pulled by `expo``@expo/cli``@expo/metro-config``postcss`.
- Reachability: the vulnerable code path is in dev build tooling, not the runtime bundle shipped to devices. End-user impact is **low**.
- Fix requires bumping Expo SDK (breaking change). Tracked separately.
CI runs `composer audit --abandoned=ignore` on every push and will fail on new vulnerabilities.
---
## Defense in Depth
### Authentication
- **2FA** — email OTP (6 digit) with optional **trust-device** cookie (UUID + secret hashed, 30 days). Verify endpoint rate-limited 5/min.
- **Passkey (WebAuthn)** — via `laragear/webauthn`, FIDO2-compliant. Login flow is challenge-bound.
- **Social OAuth** — Google, Facebook, GitHub via Socialite. Callback explicitly refuses identity-overwrite when an email is already linked to a different provider id.
- **Captcha** — Google reCAPTCHA v2/v3 toggleable per environment.
### Authorization
- Spatie `permission`/`role` system with `active-permission` gate — a permission can be disabled centrally without revoking it from each role.
- **Granular tab permissions** — 85 named permissions covering every settings tab in Global Settings and Mobile Settings. `CheckTabPermission` middleware enforces access; `@cantab`/`@managetab` Blade directives available for views.
- **Impersonation** guarded against: self-impersonate, Developer role, inactive users, and nested loop. Tracked in `Cache` for the target user awareness banner and audit logged via `ImpersonationStatusChanged` event.
### Network & Boundary
- **IP access control** — `IpAccessControl` middleware with:
- Global blacklist
- Admin route whitelist (`/users*`, `/roles*`, `/permissions*`, `/system-config*`, `/backups*`, `/admin/*`)
- Auto-block IPs that exceed configurable burst threshold (24h cooldown). Sends Telegram firewall alert.
- HSTS toggle (HTTPS only).
- **Security headers** — `SecurityHeaders` middleware sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()`, `X-XSS-Protection`, and (when HTTPS + opt-in) `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`.
- **Rate limit** — Per-endpoint throttle: `/login` (5/min), `/2fa verify` (5/min), `/forgot-password`, `/api/v1/login` (10/min), `/api/v1/register` (5/min), `/api/v1/otp/send` (5/min), `/api/v1/otp/verify` (10/min). Per-IP buckets isolated.
- **Single-session enforcement** — optional; logs out previous device when user logs in elsewhere.
### Passwords & Sessions
- **Bcrypt 12 rounds** in production (4 in test for speed).
- **Password policy** (`App\Services\Auth\PasswordPolicyService`): min/max length, mixed-case, digit, symbol, expiry, history reuse blocker (configurable N).
- **Password history** stored hashed in `password_histories`; reuse of last N raises validation error.
- **Sessions** stored in Redis. Admins can revoke sessions per user from `/system-settings/session-manager`.
### Data Integrity
- **Foreign keys** — all audit (`created_by`/`updated_by`) → `users(id) ON DELETE SET NULL`. Owned data (`password_histories`, `user_consents`, `user_trusted_devices`, `model_has_*`, `role_has_permissions`) → `ON DELETE CASCADE`. Cascade behavior is locked in by `tests/Feature/Database/CascadeIntegrityTest.php`.
- **Composite indexes** on hot lookups (`password_histories(user_id, created_at)`, `system_setting_revisions(key, created_at)`, `notifications(notifiable, read_at)`).
- **Soft deletes** — User, Role, Permission. Restore + force-delete flows tested.
### Data Retention
Semua data time-sensitive memiliki kebijakan retensi otomatis yang dijalankan oleh scheduler harian:
| Tabel | Retensi | Mekanisme |
|-------|---------|-----------|
| `otp_codes` | Setelah expired | `OtpCode::prunable()``expires_at < now()` |
| `user_trusted_devices` | Setelah expired | `UserTrustedDevice::prunable()``expires_at < now()` |
| `ai_healing_logs` | 90 hari | `AiHealingLog::prunable()``created_at` |
| `password_histories` | 365 hari | `PasswordHistory::prunable()``created_at` |
| `mobile_error_logs` | 90 hari | `MobileErrorLog::prunable()``occurred_at` |
| `ai_usage_logs` | 90 hari | `AiUsageLog::prunable()``created_at` |
| `mobile_sync_logs` | 30 hari | `MobileSyncLog::prunable()``synced_at` |
| `notifications` | 30 hari | `Notification::prunable()``created_at` |
| `activity_log` | 365 hari | `activitylog:clean` via Spatie config |
| `telescope_entries` | 48 jam | `telescope:prune --hours=48` |
| `dashboard_widget_preferences` | Dihapus saat user dihapus | FK `ON DELETE CASCADE` ke `users` |
Scheduler berjalan: `model:prune` pukul 03:00, `telescope:prune` pukul 03:05, `activitylog:clean` harian. Sesuai prinsip **data minimization** UU PDP No. 27/2022.
### Audit & Forensics
- **Spatie ActivityLog** on User, Role, Permission, SystemSetting.
- **System config revisions** — separate `system_setting_revisions` table captures actor, IP, user agent on every change.
- **`ActivityFormatter`** redacts sensitive keys (`password`, `remember_token`, `secret`, `key`, `token`, `2fa_secret`) from displayed diffs.
### Error Handling & Monitoring
- **Custom exception classes** — `App\Exceptions\{SystemConfig,BackupOperation,Monitoring}Exception` with factory methods, allowing specific handlers rather than generic `\Exception` catches.
- **Sentry** — production error reporting; sample rates set in `.env`.
- **Health endpoint** — `GET /api/health` reports DB, Redis, storage, queue; returns `503` only when any check `fail`s, `200` for `warn` (e.g. disk >90%).
---
## Quality Gate
| Check | Tool | Threshold |
|-------|------|-----------|
| Unit + feature tests | Pest 4 | All passing |
| Static analysis | Larastan level 5 + baseline | No new errors |
| Code style | Laravel Pint | All files PSR-12 compliant |
| Dependency audit | `composer audit` | 0 vulnerabilities |
| N+1 regression | Pest (Query Log) | Bounded query count |
CI workflow: `.github/workflows/ci.yml`.
---
## Disclosure Timeline (template)
```
T+0 Report received
T+48h Acknowledgement sent
T+7d Patch landed (critical) / ETA shared (others)
T+30d Coordinated disclosure window ends
```
+327
View File
@@ -0,0 +1,327 @@
# Tech Stack
Daftar lengkap teknologi yang dipakai di proyek ini, beserta penjelasan singkat kegunaannya.
---
## 1. Runtime & Bahasa
| Teknologi | Versi | Kegunaan |
|-----------|-------|----------|
| **PHP** | 8.2+ | Bahasa utama backend. Kelas utility pakai `declare(strict_types=1)`. |
| **Node.js** | 20+ | Build asset frontend (Vite) + tooling mobile |
| **PostgreSQL** | 15+ | Database relasional utama (ACID-compliant). Skema pakai FK + cascade penuh. |
| **Redis** | 7.x | Cache, session store, queue, broadcast driver |
---
## 2. Framework Inti
| Package | Versi | Kegunaan |
|---------|-------|----------|
| `laravel/framework` | ^13.0 | Framework PHP utama (routing, ORM, middleware, dll) |
| `laravel/sanctum` | ^4.0 | Autentikasi API berbasis token untuk mobile app |
| `laravel/socialite` | ^5.24 | OAuth login (Google, Facebook, GitHub) |
| `laravel/reverb` | ^1.10 | WebSocket server native untuk notifikasi real-time |
| `laravel/pulse` | ^1.7 | Monitoring performa app (request, queue, cache, slow queries) |
| `laravel/horizon` | ^5.46 | Queue dashboard (Redis-backed) |
| `laravel/breeze` | ^2.3 | Scaffolding autentikasi (login, register, reset password) |
| `laravel/tinker` | ^3.0 | REPL interaktif untuk debugging via terminal |
---
## 3. Database & Storage
| Package | Versi | Kegunaan |
|---------|-------|----------|
| `predis/predis` | ^3.4 | Client PHP untuk Redis (PSR-compliant) |
| `masbug/flysystem-google-drive-ext` | ^2.5 | Driver Flysystem untuk Google Drive (backup) |
> Driver S3 sudah built-in di Laravel — cukup set `FILESYSTEM_DISK=s3` di `.env`.
### Skema database
- 40+ tabel, semua bermigrasi (lihat `database/migrations/`).
- FK constraint penuh: audit `created_by`/`updated_by``users(id) ON DELETE SET NULL`; data milik user → `ON DELETE CASCADE` (lihat `2026_05_14_110000_add_fk_to_audit_columns.php`).
- Composite indexes pada tabel hot (`password_histories`, `system_setting_revisions`, `notifications`) — lihat `2026_05_14_100000_add_performance_indexes.php`.
- **Data retention otomatis** via Laravel `Prunable` trait pada 8 model + `telescope:prune` + `activitylog:clean`. Retention policy lengkap ada di `SECURITY.md`.
---
## 4. Autentikasi & Keamanan
| Package | Versi | Kegunaan |
|---------|-------|----------|
| `laragear/webauthn` | ^5.0 | Passkey / biometric login (FIDO2/WebAuthn) — ⚠️ marked abandoned upstream; replacement: `laravel/passkeys` |
| `anhskohbo/no-captcha` | ^3.7 | Integrasi Google reCAPTCHA v2/v3 di form login |
### Built-in (no extra package)
- **2FA via email OTP** + trust-device cookie (file: `app/Http/Controllers/Auth/TwoFactorController.php`)
- **Password policy** — `App\Services\Auth\PasswordPolicyService` (min/max/charset/expiry/history-reuse-block)
- **IP access control** — `app/Http/Middleware/IpAccessControl.php` (blacklist, admin whitelist, auto-block on burst, HSTS toggle)
- **Security headers** — `app/Http/Middleware/SecurityHeaders.php` (X-Frame, X-CTO, Referrer, Permissions-Policy, X-XSS, HSTS)
- **Session manager** — list & force-logout active sessions
- **Impersonate** — `ImpersonateController` dengan guard self/Developer/inactive + loop prevention
- **Single-session enforcement** opsional (di-toggle dari Global Settings)
---
## 5. Manajemen Hak Akses & Audit (Spatie)
| Package | Versi | Kegunaan |
|---------|-------|----------|
| `spatie/laravel-permission` | ^6.24 | Sistem role & permission granular |
| `spatie/laravel-activitylog` | ^4.10 | Audit trail — mencatat perubahan data |
| `spatie/laravel-backup` | ^10.2 | Backup database & file ke Local/S3/GDrive |
| `spatie/laravel-medialibrary` | ^11.21 | Upload & manajemen file media (avatar, dokumen) |
---
## 5b. Dashboard Widget System
Per-user persisted widget layout. Architecture:
| Layer | Class / File | Fungsi |
|-------|-------------|--------|
| Model | `DashboardWidgetPreference` | `forUser()` merge defaults + DB prefs, sorted by `sort_order` |
| Migration | `2026_05_16_220000_create_dashboard_widget_preferences_table` | `user_id` FK cascade, unique `(user_id, widget_key)` |
| Controller | `DashboardController@saveWidgetPreferences` | upsert prefs via `updateOrCreate` |
| Controller | `DashboardController@resetWidgetPreferences` | delete all prefs → restore defaults |
| Route | `POST /dashboard/widgets` (`dashboard.widgets.save`) | — |
| Partials | `resources/views/pages/dashboard/widget-*.blade.php` | cpu, ram, disk, live-users, queues, quick-actions |
| JS | SortableJS (CDN) | drag-to-reorder grid |
| Broadcasting | `DashboardStatsUpdated` event → Reverb → Echo | push stats every minute via `dashboard:broadcast-stats` |
### Sidebar Toggle
Sidebar submenus use **vanilla JS** `initSidebarSubmenus()` (bottom of `navigation.blade.php`). Uses `data-sidebar-toggle` attribute, `e.stopPropagation()`, and `cloneNode()` to replace buttons and prevent duplicate listeners. Does **not** depend on Alpine.js (theme JS conflict prevented Alpine `x-on:click` from working).
---
## 6. Modular & Arsitektur
| Package | Versi | Kegunaan |
|---------|-------|----------|
| `nwidart/laravel-modules` | ^13.0 | Memisahkan fitur ke folder `Modules/` agar codebase rapi |
### Custom Exception Hierarchy
`App\Exceptions\*` — domain-specific exceptions instead of generic `\Exception`:
- `SystemConfigException::unknownKey()`, `::imageUploadFailed()`
- `BackupOperationException::missingBinary()`, `::diskNotConfigured()`, `::restoreFailed()`
- `MonitoringException::unsupportedOs()`, `::probeFailed()`
---
## 7. Admin Panel & API Docs
| Package | Versi | Kegunaan |
|---------|-------|----------|
| `filament/filament` | ^5.5 | Admin panel builder (resource management cepat) |
| `darkaonline/l5-swagger` | ^11.0 | Auto-generate Swagger/OpenAPI docs dari annotation. Spec di `storage/api-docs/`. |
---
## 8. Monitoring & Error Tracking
| Package | Versi | Kegunaan |
|---------|-------|----------|
| `sentry/sentry-laravel` | ^4.25 | Error monitoring & performance tracking untuk production |
> Set `SENTRY_LARAVEL_DSN` di `.env` untuk mengaktifkan. Log error otomatis terkirim ke Sentry dashboard.
Endpoint `GET /api/health` mengembalikan status `database`/`redis`/`storage`/`queue`. Kembalikan `503` hanya saat ada check yang `fail``warn` (disk >90%) tetap `200`.
---
## 9. Frontend Build
| Package | Versi | Kegunaan |
|---------|-------|----------|
| `vite` | ^7.0 | Build tool — hot reload & bundling JS/CSS |
| `laravel-vite-plugin` | ^2.0 | Integrasi Vite dengan Blade |
| `tailwindcss` | ^4.2 | CSS utility-first |
| `@tailwindcss/forms` | ^0.5.2 | Plugin Tailwind untuk styling form |
| `alpinejs` | ^3.4 | Reactive JS ringan (toggle, modal, tabs) |
| `axios` | ^1.15 | HTTP client untuk AJAX |
| `laravel-echo` | ^2.3 | Client untuk subscribe ke WebSocket channel |
| `pusher-js` | ^8.5 | Transport layer untuk Echo (kompatibel Reverb) |
| `rollup` | ^4.60 | Module bundler (digunakan Vite secara internal) |
| `concurrently` | ^9.0 | Menjalankan beberapa command paralel saat dev |
### Dev Script (`composer run dev`)
Menjalankan beberapa proses secara paralel:
| Proses | Command |
|--------|---------|
| SERVER | `php artisan serve --host=0.0.0.0 --port=8000` |
| VITE | `npm run dev` |
| QUEUE | `php artisan queue:listen --tries=1` |
### Scheduled Tasks (Production)
| Waktu | Command | Fungsi |
|-------|---------|--------|
| Setiap menit | `dashboard:broadcast-stats` | Broadcast stats dashboard ke WebSocket channel `admin.monitoring` (withoutOverlapping) |
| Setiap menit | `MaintenanceManagementService::autoCheckAndRelease()` | Auto-release maintenance mode |
| Setiap menit | `WorkerHeartbeatJob` | Queue worker monitoring |
| Setiap 30 menit | `system:health-check` | System health check |
| Harian 03:00 | `model:prune` | Pruning OtpCode, UserTrustedDevice, AiHealingLog, PasswordHistory, dll |
| Harian 03:05 | `telescope:prune --hours=48` | Hapus Telescope entries > 48 jam |
| Harian | `activitylog:clean` | Hapus activity log > 365 hari |
| Senin 07:00 | `backups:verify` | Verifikasi integritas backup |
| Senin 07:05 | `permissions:audit --json` | Audit permission (log only) |
| Senin 08:00 | `system:send-digest` | Weekly health digest ke admin |
| Dinamis | DB backup + cleanup | Frekuensi dikonfigurasi dari Global Settings |
> Untuk dev penuh (termasuk Reverb + Scheduler), pakai Sail (`./vendor/bin/sail up -d`).
---
## 10. Frontend Library (CDN/Blade)
Dimuat via CDN di template Blade:
| Library | Kegunaan |
|---------|----------|
| Bootstrap 5 | Layout grid & komponen UI |
| Bootstrap Icons | Ikon SVG |
| jQuery | DOM manipulation & AJAX |
| SweetAlert2 | Dialog & notifikasi toast |
| CKEditor 5 | WYSIWYG editor (Privacy Policy, ToS, About, dll) |
| FilePond | Upload file drag-and-drop |
| Animate.css | Animasi entrance/exit elemen |
| Marked.js | Render Markdown untuk laporan analisis AI |
| Choices.js | Dropdown searchable & multi-select |
| SortableJS | Drag-to-reorder dashboard widget grid (loaded via CDN in dashboard.blade.php) |
| Google Fonts | Inter, Outfit, Fira Code |
---
## 11. Development & Quality Tools
### Code Quality
| Package | Versi | Kegunaan |
|---------|-------|----------|
| `laravel/pint` | ^1.24 | Code formatter (PSR-12). Wajib hijau sebelum merge. |
| `larastan/larastan` | ^3.9 | Static analysis Laravel-aware (PHPStan). Level 5 + baseline. |
| `laravel/sail` | ^1.41 | Docker dev environment (app + Postgres + Redis) |
| `laravel/pail` | ^1.2 | Live log viewer di terminal |
| `laravel/telescope` | ^5.20 | Debug tool (request, query, job, mail) — hanya dev |
| `laravel/boost` | ^2.0 | AI assistant untuk Laravel dev |
### Testing
| Package | Versi | Kegunaan |
|---------|-------|----------|
| `pestphp/pest` | ^4.0 | Testing framework modern |
| `pestphp/pest-plugin-laravel` | ^4.0 | Helper Pest untuk Laravel |
| `mockery/mockery` | ^1.6 | Library mocking untuk test |
| `fakerphp/faker` | ^1.23 | Generator data dummy |
| `nunomaduro/collision` | ^8.6 | Error reporting yang readable di terminal |
### Test Suite Statistics
| Kategori | File | Tests |
|----------|------|-------|
| Feature: Auth + WebAuthn + Social + 2FA + Impersonate | 9 | ~50 |
| Feature: AccessControl (User/Role/Permission) | 3 | 37 |
| Feature: Middleware (IP, ActivePermission, Legal, PwdExpiry, SecurityHeaders, CheckTabPermission) | 6 | 30 |
| Feature: Services (SystemConfig, PasswordPolicy, Backup) | 3 | 31 |
| Feature: Performance (N+1 regression) | 1 | 3 |
| Feature: Database (FK + Cascade) | 1 | 9 |
| Feature: API (Health, MobileConfig, Rate-limit, OTP, AuthAPI, DeviceToken) | 6 | 25 |
| Feature: Dashboard (widget prefs, broadcast event) | 2 | 18 |
| Feature: Helpers (ApiResponse, PasswordRule) | 2 | 18 |
| Unit: Pure logic (Formatter, Caster, Helpers, Exceptions) | 5 | 88 |
| Granular tab permission system | — | +62 |
| **Total** | **38** | **371** |
Run via `./vendor/bin/sail artisan test`. Avg runtime ~35s.
---
## 12. CI/CD
Workflow di `.github/workflows/ci.yml` (GitHub Actions). 3 job paralel:
| Job | Tools |
|-----|-------|
| `test` | Pest 4 (Postgres 15 + Redis 7 service containers) |
| `lint` | `pint --test` + `composer audit` + `permissions:audit` |
| `static-analysis` | Larastan level 5 + baseline |
Push ke `main`/`develop`/`config`/`advanced` dan PR ke `main`/`develop` mentrigger pipeline.
---
## 13. Integrasi Eksternal (Opsional)
Sebagian besar diatur dari **Global Settings** di admin panel — tidak perlu edit `.env`.
| Layanan | Kegunaan |
|---------|----------|
| **OpenAI GPT** | AI assistant di admin panel |
| **Google Gemini** | AI assistant alternatif |
| **Anthropic Claude** | AI assistant alternatif |
| **DeepSeek** | AI assistant alternatif |
| **xAI Grok** | AI assistant alternatif |
| **Mistral AI** | AI assistant alternatif |
| **OpenRouter** | Gateway multi-provider AI |
| **SAP NW RFC** | Koneksi ke sistem SAP ERP |
| **Google Drive** | Cloud backup |
| **Amazon S3** | Cloud backup |
| **SMTP (Mailgun/SES)** | Pengiriman email transaksional |
| **Telegram Bot** | Notifikasi ke channel Telegram (incl. firewall block alert) |
| **Google reCAPTCHA** | Anti-bot di form login |
| **Firebase Cloud Messaging** | Push notification ke mobile (device token) |
| **Sentry** | Error monitoring & performance tracing |
---
## Ringkasan Arsitektur
```
┌─────────────────────────────────────────────────────────┐
│ Browser / Mobile App (React Native) │
└────────────┬────────────────────────────────┬───────────┘
│ HTTPS (+ security headers) │ HTTPS + WS
▼ ▼
┌──────────┐ ┌──────────┐
│ Nginx │◄────────────────────│ Reverb │ WebSocket
└─────┬────┘ └─────┬────┘
▼ ▼
┌──────────────────────────────────────────────┐
│ Laravel 13 (PHP-FPM) │
│ │
│ Global middleware: │
│ ┌──────────────────────────────────────┐ │
│ │ SecurityHeaders │ │
│ │ IpAccessControl │ │
│ │ PasswordExpiry │ │
│ │ CheckLegalAgreement │ │
│ │ ThrottleRequests (per route) │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ Web │ │ API v1 │ │ Reverb │ │
│ │ Routes │ │ Sanctum │ │ Broadcast │ │
│ └────┬─────┘ └────┬─────┘ └──────┬──────┘ │
└───────┼────────────┼──────────────┼──────────┘
│ │ │
┌──────────▼───┐ ┌────▼──┐ ┌──────▼─────┐
│ PostgreSQL 15 │ │Redis 7│ │ Filesystem │
│ (data utama) │ │cache, │ │ local/S3/ │
│ FK + indexes │ │queue, │ │ GDrive │
│ + cascade │ │session│ │ │
└───────────────┘ └───────┘ └────────────┘
┌──────▼──────┐
│ Sentry │
│ (error mon) │
└─────────────┘
```
+262
View File
@@ -0,0 +1,262 @@
# User Guide — Panduan Admin
Panduan ini untuk **Administrator** atau **Super Admin** yang mengoperasikan aplikasi.
---
## Login
1. Buka `https://domain.com/login`
2. Masukkan email & password
3. (Opsional) jika **2FA** diaktifkan, kode 6 digit dikirim ke email — input di halaman `/2fa`
4. (Opsional) centang **Trust this device** untuk skip 2FA selama 30 hari (cookie aman)
5. (Opsional) gunakan **Passkey (biometrik)** jika sudah didaftarkan di profil
6. (Opsional) login via **Google / Facebook / GitHub** jika Social OAuth diaktifkan
> Akun default setelah seeder: lihat tabel di README.md. Jika perlu reset, jalankan:
> ```bash
> ./vendor/bin/sail artisan db:seed --class=AdminUserSeeder
> ./vendor/bin/sail artisan cache:clear
> ```
### Rate Limit
Untuk mencegah brute force:
| Endpoint | Limit |
|----------|-------|
| `/login` (web) | 5/menit per IP |
| `/2fa` verify | 5/menit per IP |
| `/forgot-password` | dibatasi via Spatie throttle |
| `/api/v1/login` (mobile) | 10/menit per IP |
| `/api/v1/otp/send` | 5/menit per IP |
| `/api/v1/otp/verify` | 10/menit per IP |
Jika kena rate limit, response `429 Too Many Requests` — tunggu 1 menit.
---
## Dashboard
Halaman pertama setelah login. Menampilkan statistik sistem secara **real-time** via WebSocket (Laravel Reverb). Jika koneksi WebSocket tidak tersedia, data di-refresh otomatis tiap 30 detik.
### Widget Bawaan
| Widget | Isi |
|--------|-----|
| CPU Load | Persentase penggunaan CPU dengan sparkline |
| Memory | RAM used/total |
| Storage | Disk used/total |
| Live Users | Sesi aktif saat ini |
| Queue Stats | Job pending/processed/failed |
| Activity Feed | Log aktivitas terbaru (butuh permission `view health and logs`) |
| AI Security Insight | Skor keamanan terakhir dari AI analysis (butuh permission `view ai log analysis`) |
### Kustomisasi Dashboard
1. Klik tombol **Customize** di pojok kanan atas halaman Dashboard
2. Panel kustomisasi muncul dengan daftar widget:
- Toggle switch untuk **tampilkan/sembunyikan** setiap widget
- Drag-and-drop widget untuk **mengubah urutan** tampilan
3. Klik **Save Layout** — preferensi tersimpan di database per akun
4. Klik **Reset to Default** untuk kembali ke urutan dan visibilitas bawaan
> Preferensi layout **per-user** — setiap admin bisa punya tata letak sendiri.
---
## Menu Utama
### 1. Users — Manajemen Pengguna
**Akses:** sidebar → User Management
- Tambah / edit / hapus user (soft delete — bisa di-restore)
- Atur role (Super Admin, Admin, Custom)
- Aktif / nonaktifkan akun
- **Bulk action** — aktifkan/nonaktifkan/hapus banyak user sekaligus
- **Impersonate** — login sebagai user lain untuk debugging
- Tidak bisa impersonate diri sendiri
- Tidak bisa impersonate Super Admin/Developer
- Tidak bisa impersonate user inactive
- Tidak bisa nested impersonate (loop prevention)
> Force delete diri sendiri di-blokir oleh sistem — gunakan akun admin lain bila perlu.
### 2. Roles & Permissions
**Akses:** sidebar → Access Control
- Buat role baru dan pilih permission yang diizinkan
- **Permission dikelompokkan per kategori** dalam dua panel:
- Panel kiri (**Available**) — permission yang belum diberikan ke role ini
- Panel kanan (**Assigned**) — permission yang sudah diberikan
- Pindahkan dengan **drag-and-drop** atau **double-click** item
- Gunakan **search** di masing-masing panel untuk filter cepat
- **Category group headers** muncul otomatis di panel Assigned saat ada permission dari kategori tersebut
- **85 granular tab permissions** — setiap tab di Global Settings dan Mobile Settings punya permission sendiri (mis. `manage global settings password policy`, `view mobile settings kill switch`)
- Role dan permission bisa diaktif/nonaktifkan tanpa dihapus
- Audit trail tersedia: setiap perubahan role/permission tercatat
- Tidak bisa archive role yang masih dipakai user — pindahkan user dulu
### 3. Action Logs — Audit Trail
**Akses:** sidebar → Action History
Mencatat semua perubahan data: siapa, kapan, apa yang diubah, dari IP mana. Berguna untuk audit & forensik.
Sensitive fields (password, token, secret, 2fa_secret) otomatis di-redact dari log entry.
---
## System Settings
### Global Settings
**Akses:** System Settings → Global Settings
| Tab | Pengaturan |
|-----|------------|
| General (Branding) | Nama app, logo, favicon, tagline, footer, landing page visibility, locale |
| Login Security | Max attempts, lockout duration, 2FA, OTP, captcha (reCAPTCHA v2/v3), passkey (WebAuthn) |
| Password Policy | Panjang min/max, karakter wajib (uppercase/lowercase/angka/simbol), expiry, riwayat |
| Social Login / OAuth | Toggle Google/Facebook/GitHub OAuth, client ID & secret, callback URL |
| IP & Access | Whitelist/blacklist IP admin, CORS, rate limit, force HTTPS, HSTS |
| Notification | SMTP (host, port, encryption, from address), Telegram bot token & chat ID |
| AI Config | Provider (GPT/Gemini/Claude/DeepSeek/Grok/Mistral/OpenRouter), API key, model, temperature |
| SAP Integration | RFC host, system number, client, user, password — termasuk tombol Test Connection |
| Backup & Storage | Driver (local/S3/GDrive), jadwal, retensi, enkripsi, notifikasi |
| Maintenance | Toggle, pesan, judul, countdown, secret key, IP whitelist |
| Legal & Content | Privacy Policy, Terms of Use, About, Security Policy, Help Center (editor WYSIWYG) |
| Regional | Timezone, format tanggal, format waktu |
| Session | Driver, lifetime, single session, auto logout, remember me, cookie settings |
> Tombol **Save Configuration** selalu muncul di pojok kanan bawah (floating). Setelah save, cache otomatis di-invalidate.
### Mobile Settings
**Akses:** System Settings → Mobile Settings
Kontrol konfigurasi aplikasi mobile dari jarak jauh — warna tema, base URL API, FCM topic, biometric login, kill switch, pesan maintenance mobile, dll. Perubahan langsung tersinkron ke aplikasi mobile via endpoint `/api/v1/mobile/sync` (dengan ETag caching).
### Maintenance Mode
**Akses:** System Settings → Maintenance (atau via tab di Global Settings)
1. Aktifkan toggle **Enable Maintenance Mode**
2. Isi judul & pesan untuk pengunjung
3. (Opsional) set **End Time** untuk countdown timer — aplikasi auto-release saat waktu berlalu
4. (Opsional) isi **Secret Key** — admin bisa tetap akses via `domain.com/{secret}`
5. (Opsional) isi **IP Whitelist** — IP yang dikecualikan dari maintenance
6. Klik **Apply Maintenance Settings**
> Live preview ditampilkan persis seperti yang dilihat pengunjung.
### Backup & Restore
**Akses:** System Settings → Backup & Storage
- Pilih **driver**: Local / Amazon S3 / Google Drive
- Atur jadwal backup otomatis, retensi (berapa backup disimpan), dan enkripsi AES-256
- Operasi: Run Backup Now, Download, Restore, Delete
- Notifikasi backup bisa dikirim ke email atau webhook
- Tombol **Test Connection** untuk verifikasi driver sebelum simpan
### System Monitoring
**Akses:** System Settings → System Monitoring
| Tab | Isi |
|-----|-----|
| Logs | Laravel error log (filter level, search, download, clear) |
| SAP Logs | Log integrasi SAP RFC — status request dan error |
| Mobile Logs | Log yang dikirim dari aplikasi mobile (`/api/v1/mobile/log`) |
| Background Jobs | Status queue, retry / delete failed jobs |
| AI Usage | Riwayat penggunaan AI — provider, model, token, waktu |
| Health | CPU, memory, disk usage secara real-time |
### Notification Center
**Akses:** sidebar → Notifications (ikon lonceng)
Pusat notifikasi sistem real-time via WebSocket (Reverb). Admin bisa:
- Melihat notifikasi masuk
- Menandai sudah dibaca (satu atau semua)
- Broadcast notifikasi ke semua user atau role tertentu
### Session Manager
**Akses:** System Settings → Session Manager
Lihat semua sesi aktif seluruh user. Bisa memaksa logout user tertentu jika dicurigai kompromi akun.
Jika `Single Session` aktif di Global Settings, user otomatis di-logout dari device lama saat login di device baru.
---
## Profil & Keamanan Akun
**Akses:** klik nama/avatar di pojok kanan atas → Profile
- Update nama, email, avatar
- Ganti password (cek policy: min 12, mixed-case, digit, symbol; tidak boleh sama dengan password sebelumnya jika history blocker aktif)
- Daftarkan / hapus **Passkey (biometrik)**
- Lihat & cabut sesi aktif
### Password Expiry
Jika admin mengaktifkan **Password Expiry** di Global Settings → Password Policy, user akan diarahkan ke `/profile/password` saat password lewat masa berlaku, dengan pesan warning.
---
## Halaman Legal (UU PDP)
Halaman publik yang bisa diakses tanpa login:
| URL | Konten |
|-----|--------|
| `/legal/privacy` | Kebijakan Privasi |
| `/legal/tos` | Syarat & Ketentuan |
| `/legal/about` | Tentang Biiproject |
| `/legal/security` | Kebijakan Keamanan |
| `/legal/help` | Pusat Bantuan & FAQ |
Konten semua halaman ini dikelola via Global Settings → Legal & Content.
Jika versi dokumen (`pdp_document_version` atau `tos_document_version`) diperbarui, user yang sudah login akan diarahkan ke halaman **re-agree** (`/legal/re-agree`) dan wajib menyetujui ulang sebelum bisa mengakses aplikasi.
---
## Tips Operasional
1. **Setelah menjalankan seeder**, selalu clear cache: `./vendor/bin/sail artisan cache:clear`
2. **Backup sebelum update** besar atau perubahan konfigurasi penting
3. Selalu uji koneksi (Email, SAP, Backup) lewat tombol **Test Connection** sebelum save
4. Setelah ubah `.env`, jalankan: `./vendor/bin/sail artisan optimize:clear`
5. Cek **Action Logs** secara berkala untuk audit keamanan
6. **Maintenance Mode**: selalu set Secret Key agar tidak terkunci dari panel sendiri
7. **IP Whitelist Admin**: tambahkan dulu IP statis Anda sebelum mengaktifkan, agar tidak terkunci
8. **2FA**: untuk akun super admin, sangat disarankan diaktifkan
9. **Single Session**: untuk environment sensitif (PII, finance), aktifkan agar 1 akun = 1 device
---
## Bantuan & Troubleshooting
| Masalah | Solusi |
|---------|--------|
| Lupa password admin | `./vendor/bin/sail artisan tinker` → reset via model `User` |
| Panel tidak responsif / data lama | `./vendor/bin/sail artisan optimize:clear` + `cache:clear` |
| Error 500 | Cek `storage/logs/laravel.log` atau menu System Monitoring → Logs |
| Setting yang baru disimpan tidak muncul | `./vendor/bin/sail artisan cache:clear` |
| User tidak bisa login | Cek status akun di User Management, pastikan role & permission aktif |
| Notifikasi real-time tidak muncul | Pastikan Reverb berjalan: `supervisorctl status biiproject-reverb` |
| Dashboard stats tidak update otomatis | Cek koneksi WebSocket di browser DevTools (Console). Jika gagal, refresh — akan fallback ke polling 30 detik. |
| Widget tersimpan tidak muncul | Buka Customize panel, klik Reset to Default, lalu coba Save Layout ulang. |
| Tab Settings tidak bisa diakses | Permission tab-level mungkin belum diberikan ke role Anda — minta Super Admin assign via Roles & Permissions. |
| Kena rate limit terus | Cek IP whitelist admin, tunggu 1 menit, atau reset via `php artisan cache:clear` |
| 2FA email tidak masuk | Verifikasi SMTP di Global Settings → Notifications + cek log queue |
| OAuth Google/Facebook gagal | Cek `/auth/callback` reachable (bukan 404), Client ID/Secret valid, redirect URI cocok |
| Health endpoint return 503 | Buka `/api/health`, lihat field `checks.*` — yang `fail` mengindikasikan dependensi rusak. `warn` masih 200. |
| User di-logout otomatis | Single-session mungkin aktif (login dari device lain), atau password expired |
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# --- Colors for Output ---
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${BLUE}==============================================${NC}"
echo -e "${GREEN} Production Readiness Checklist ${NC}"
echo -e "${BLUE}==============================================${NC}"
# 1. Check PHP Version
PHP_VER=$(php -r 'echo PHP_VERSION;')
echo -ne "Checking PHP Version (8.2+ required)... "
if [[ $(echo "$PHP_VER 8.2" | awk '{print ($1 >= $2)}') -eq 1 ]]; then
echo -e "${GREEN}OK ($PHP_VER)${NC}"
else
echo -e "${RED}FAIL ($PHP_VER)${NC}"
fi
# 2. Check PHP Extensions
echo -e "\nChecking PHP Extensions:"
EXTENSIONS=("pgsql" "redis" "curl" "mbstring" "xml" "zip" "bcmath" "intl" "gd")
for ext in "${EXTENSIONS[@]}"; do
echo -ne " - $ext... "
if php -m | grep -qi "$ext"; then
echo -e "${GREEN}INSTALLED${NC}"
else
echo -e "${RED}MISSING${NC}"
fi
done
# 3. Check .env settings
echo -e "\nChecking .env settings:"
if [ -f .env ]; then
APP_ENV=$(grep APP_ENV .env | cut -d '=' -f2)
APP_DEBUG=$(grep APP_DEBUG .env | cut -d '=' -f2)
echo -ne " - APP_ENV... "
if [[ "$APP_ENV" == "production" ]]; then
echo -e "${GREEN}production${NC}"
else
echo -e "${YELLOW}$APP_ENV (Warning: not production)${NC}"
fi
echo -ne " - APP_DEBUG... "
if [[ "$APP_DEBUG" == "false" ]]; then
echo -e "${GREEN}false${NC}"
else
echo -e "${RED}true (CRITICAL: Disable debug in production!)${NC}"
fi
else
echo -e "${RED}.env file missing!${NC}"
fi
# 4. Check Directory Permissions
echo -e "\nChecking Directory Permissions:"
DIRS=("storage" "bootstrap/cache")
for dir in "${DIRS[@]}"; do
echo -ne " - $dir writable... "
if [ -w "$dir" ]; then
echo -e "${GREEN}YES${NC}"
else
echo -e "${RED}NO${NC}"
fi
done
# 5. Check Mobile API URL
echo -e "\nChecking Mobile API Configuration:"
if [ -f mobile/services/api.ts ]; then
if grep -q "Constants.expoConfig?.extra?.apiUrl" mobile/services/api.ts; then
echo -e "${GREEN}Flexible (Production Ready)${NC}"
else
echo -e "${YELLOW}Hardcoded (Check mobile/services/api.ts)${NC}"
fi
fi
# 6. Check Database Connectivity
echo -e "\nChecking Database Connectivity... "
php artisan db:monitor --quiet > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo -e "${GREEN}CONNECTED${NC}"
else
echo -e "${RED}FAILED${NC}"
fi
echo -e "\n${BLUE}==============================================${NC}"
echo -e "${YELLOW}Next Steps for Smooth Deployment:${NC}"
echo -e "1. Run ${BLUE}php artisan optimize${NC} in production."
echo -e "2. Run ${BLUE}npm run build${NC} for frontend assets."
echo -e "3. Ensure ${BLUE}Supervisor${NC} is running for Queue and Reverb."
echo -e "4. Check ${BLUE}Nginx${NC} logs for any proxy issues."
echo -e "${BLUE}==============================================${NC}"
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* SAP RFC Environment Checker
* run with: php check-sap.php
*/
echo "\e[1;34m=== SAP RFC ENVIRONMENT CHECKER ===\e[0m\n\n";
// 1. Check PHP Extension
echo "[1] Checking PHP Extension 'sapnwrfc'...\n";
if (extension_loaded('sapnwrfc')) {
echo "\e[32m[OK]\e[0m Extension 'sapnwrfc' is loaded.\n";
} else {
echo "\e[31m[FAIL]\e[0m Extension 'sapnwrfc' is NOT loaded.\n";
echo " Tip: Check your php.ini and ensure 'extension=sapnwrfc.so' is present.\n";
}
// 2. Check SAPNWRFC Class
echo "\n[2] Checking SAPNWRFC Class existence...\n";
if (class_exists('SAPNWRFC\Connection')) {
echo "\e[32m[OK]\e[0m SAPNWRFC classes are available.\n";
} else {
echo "\e[31m[FAIL]\e[0m SAPNWRFC classes are NOT found.\n";
}
// 3. Check SAP SDK via ldconfig
echo "\n[3] Checking SAP NW RFC SDK in system libraries...\n";
$ldOutput = shell_exec('ldconfig -p | grep sap');
if ($ldOutput) {
echo "\e[32m[OK]\e[0m SAP libraries found in ldconfig:\n";
echo $ldOutput;
} else {
echo "\e[33m[WARNING]\e[0m No SAP libraries found in ldconfig.\n";
echo " You might need to add the SDK 'lib' path to /etc/ld.so.conf.d/sap.conf\n";
}
// 4. Check for common SDK locations
echo "\n[4] Searching for SDK in common paths...\n";
$commonPaths = [
'/usr/local/sap/nwrfcsdk',
'/opt/sap/nwrfcsdk',
'/usr/sap/nwrfcsdk',
];
$found = false;
foreach ($commonPaths as $path) {
if (is_dir($path)) {
echo "\e[32m[FOUND]\e[0m SDK directory detected at: $path\n";
$found = true;
}
}
if (! $found) {
echo "\e[31m[NOT FOUND]\e[0m Could not find SDK in common locations.\n";
}
// 5. Check Project Config
echo "\n[5] Checking Project .env configuration...\n";
$envContent = file_get_contents('.env');
if (strpos($envContent, 'SAP_RFC_ASHOST') !== false) {
echo "\e[32m[OK]\e[0m SAP configuration keys found in .env\n";
} else {
echo "\e[33m[INFO]\e[0m SAP keys not found in .env. Ensure you've saved them via the System Configuration page.\n";
}
echo "\n\e[1;34m=== CHECK COMPLETED ===\e[0m\n";
+43
View File
@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android
+202
View File
@@ -0,0 +1,202 @@
# biiproject Mobile
Aplikasi mobile **biiproject** dibangun dengan **React Native (Expo SDK 54+)**. Dokumentasi ini mencakup arsitektur, tech stack, panduan pengembangan, dan integrasi dengan backend Laravel.
---
## Tech Stack Mobile
| Teknologi | Kegunaan |
|-----------|----------|
| **React Native** | Framework aplikasi cross-platform |
| **Expo SDK 54+** | Managed workflow — EAS Build, updates, native modules |
| **Expo Router** | File-based navigation (seperti Next.js untuk mobile) |
| **React Native Reanimated 3** | Animasi performa tinggi (native thread) |
| **Expo Image** | Image loading dengan cache cepat & lazy load |
| **expo-haptics** | Feedback getaran taktil pada interaksi tombol |
| **BlurView** | Efek glassmorphism pada header & navigasi |
| **Axios** | HTTP client untuk komunikasi dengan API Laravel |
---
## Fitur & Teknologi UI
### Performance Engine
- `FlatList` dioptimalkan dengan `initialNumToRender`, `windowSize`, `removeClippedSubviews`
- Scrolling tetap 60 FPS meskipun ribuan item dimuat
### Animasi Cinematic
- **Staggered Entry**: konten muncul bertahap menggunakan `react-native-reanimated`
- **Spring Physics**: animasi pegas organik — tidak kaku/linear
### Modern Aesthetics
- **Glassmorphism**: `BlurView` pada header dan navigasi
- **Dynamic Theming**: warna sinkron otomatis dengan konfigurasi dari Laravel Admin Panel via `/api/v1/mobile/sync`
- **Edge-to-Edge Design**: memaksimalkan seluruh area layar termasuk notch dan bottom bar
### Interactive Feedback
- **Haptic Engine**: `expo-haptics` pada setiap tap & aksi sukses
- **Adaptive Layout**: responsif terhadap orientasi layar dan ukuran font sistem (aksesibilitas)
---
## API Endpoints (Backend Laravel)
Base URL: `https://domain.com/api`
### Public Endpoints
| Method | URL | Keterangan |
|--------|-----|------------|
| GET | `/health` | Health check server |
| GET | `/v1/app-config` | Konfigurasi publik aplikasi |
| GET | `/v1/mobile/sync` | Sinkronisasi konfigurasi mobile dari admin panel |
| POST | `/v1/login` | Login (rate limit: 10/menit) |
| POST | `/v1/register` | Registrasi akun baru (rate limit: 5/menit) |
| POST | `/v1/forgot-password` | Kirim link reset password (rate limit: 5/menit) |
| POST | `/v1/otp/send` | Kirim OTP ke email (rate limit: 5/menit) |
| POST | `/v1/otp/verify` | Verifikasi OTP (rate limit: 10/menit) |
### Authenticated Endpoints (Sanctum Token)
| Method | URL | Keterangan |
|--------|-----|------------|
| GET | `/v1/user` | Data user yang sedang login |
| POST | `/v1/logout` | Logout & cabut token |
| GET | `/v1/dashboard` | Data dashboard mobile |
| POST | `/v1/profile/update` | Update data profil |
| POST | `/v1/profile/avatar` | Upload foto avatar |
| POST | `/v1/profile/password` | Ganti password |
| DELETE | `/v1/profile/delete` | Hapus akun |
| POST | `/v1/mobile/log` | Kirim log error dari mobile (rate limit: 60/menit) |
| POST | `/v1/devices/register` | Daftarkan device token untuk FCM push notification |
| DELETE | `/v1/devices/unregister` | Hapus device token |
### Autentikasi
Gunakan Bearer token dari response `/v1/login`:
```
Authorization: Bearer <sanctum-token>
```
### ETag pada `/v1/mobile/sync`
Endpoint sync mendukung **conditional GET**:
```http
GET /api/v1/mobile/sync
200 OK
ETag: "abc123..."
{ "status": "success", "data": {...} }
GET /api/v1/mobile/sync
If-None-Match: "abc123..."
304 Not Modified
```
Aplikasi mobile sebaiknya simpan ETag dari response sebelumnya dan kirim ulang di header `If-None-Match` untuk menghemat bandwidth.
### Health Check Status
`GET /api/health` mengembalikan:
- `200 healthy` — semua check OK
- `200 warn` — ada check yang `warn` (mis. disk >90%), aplikasi masih berfungsi
- `503 degraded` — ada check yang `fail`, koneksi backend bermasalah
Mobile sebaiknya treat `200 warn` sebagai healthy dan hanya menampilkan banner peringatan saat `503`.
---
## Panduan Pengembangan
### 1. Persiapan Environment
Pastikan tools berikut terpasang:
- **Node.js** 20+
- **Java JDK** 17 (untuk Android build)
- **Android SDK** dengan `platform-tools` (adb)
- **Expo CLI**: `npm install -g expo-cli`
### 2. Instalasi & Menjalankan Dev Server
```bash
cd mobile
npm install
npx expo start
```
Gunakan **Expo Go** di HP atau emulator untuk melihat perubahan secara real-time.
### 3. Sinkronisasi API URL
Aplikasi mendeteksi host API berdasarkan lingkungan:
- **Development**: IP lokal komputer otomatis terdeteksi
- **Production**: domain diatur di `ConfigContext.tsx`
### 4. Build APK (Android)
**Opsi A — Build & Install ke HP (HP harus terhubung via USB):**
```bash
npx expo run:android --variant release
```
**Opsi B — Build APK tanpa HP (via Gradle langsung):**
```bash
cd android && ./gradlew assembleRelease
```
Output APK: `android/app/build/outputs/apk/release/app-release.apk`
**Opsi C — EAS Build (Cloud, direkomendasikan untuk production):**
```bash
npx eas build --platform android --profile production
```
---
## Push Notification (FCM)
1. Buat project di [Firebase Console](https://console.firebase.google.com)
2. Unduh `google-services.json` → letakkan di `mobile/android/app/`
3. Saat user login, app otomatis memanggil `POST /api/v1/devices/register` dengan FCM token
4. Saat logout, app memanggil `DELETE /api/v1/devices/unregister`
5. Push notification dikirim dari backend via Firebase Admin SDK
---
## Konfigurasi Remote (Mobile Settings)
Admin dapat mengubah konfigurasi aplikasi mobile dari panel admin tanpa update APK:
**Akses:** Admin Panel → System Settings → Mobile Settings
Konfigurasi yang bisa dikontrol remote:
- Warna tema (primary, secondary, accent)
- Base URL API
- FCM topic untuk broadcast
- Toggle biometric login
- Kill switch (paksa update)
- Pesan maintenance khusus mobile
Aplikasi menarik konfigurasi ini via `GET /api/v1/mobile/sync` setiap kali dibuka.
---
## Known Issues (Supply Chain)
`npm audit` melaporkan **4 moderate** advisory di rantai dependensi:
```
postcss < 8.5.10 (GHSA-qx2v-qp2m-jg93)
↑ via @expo/metro-config
↑ via @expo/cli
↑ via expo
```
Reachable hanya di build tooling (`metro` saat development), bukan di runtime bundle yang ter-deploy ke device. Fix membutuhkan bump Expo SDK ke versi terbaru (breaking change). Dilacak di `SECURITY.md` root project.
---
*Developed with ❤️ by biiproject Tech Team 2026*
+69
View File
@@ -0,0 +1,69 @@
{
"expo": {
"name": "mobile",
"slug": "mobile",
"version": "1.1.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobile",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.mobile"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.anonymous.mobile",
"usesCleartextTraffic": true,
"permissions": [
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE"
]
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
"expo-secure-store",
"expo-web-browser",
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to let you share them with your friends."
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": false
},
"extra": {
"apiUrl": "http://zqfwerzr7b.laravel-sail.site:8080",
"router": {},
"eas": {
"projectId": "3425009e-9c38-4e40-b789-cfbae7297859"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Stack } from 'expo-router';
import { useAppTheme } from '../../context/ThemeContext';
export default function AuthLayout() {
const { colors } = useAppTheme();
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
animation: 'fade',
}}
/>
);
}
+141
View File
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import {
View, Text, StyleSheet,
TouchableOpacity, ActivityIndicator, Platform
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Image } from 'expo-image';
import { Feather } from '@expo/vector-icons';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import { useToast } from '../../context/ToastContext';
import { useForm } from '../../hooks/useForm';
import { useAppTheme } from '../../context/ThemeContext';
import { useAppConfig } from '../../context/ConfigContext';
import { useTranslation } from '../../context/LanguageContext';
import { AIInput, AIButton, AppScreen } from '../../components/UI';
export default function ForgotPasswordScreen() {
const router = useRouter();
const { showToast } = useToast();
const { colors, isDark } = useAppTheme();
const { config } = useAppConfig();
const { t } = useTranslation();
const { values, handleChange } = useForm({ email: '' });
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const handleReset = async () => {
if (!values.email.includes('@')) {
showToast(t('invalidEmail'), 'error');
return;
}
setLoading(true);
try {
// Technical Note: This calls the real API from ApiService if implemented,
// but here we keep the simulation logic as requested for demo stability.
await new Promise(res => setTimeout(res, 2000));
setSent(true);
showToast(t('emailSent'), 'success');
} catch (err) {
showToast(t('sendFailed'), 'error');
} finally {
setLoading(false);
}
};
const cardBg = colors.surface;
const border = colors.border;
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<SafeAreaView style={{ flex: 1 }}>
{/* Back Button */}
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()}>
<Feather name="arrow-left" size={24} color={colors.text} />
</TouchableOpacity>
<KeyboardAwareScrollView
enableOnAndroid
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}
showsVerticalScrollIndicator={false}
style={styles.scroll}
>
{/* Header */}
<View style={styles.header}>
<View style={[styles.iconWrap, { backgroundColor: colors.surface }]}>
{config?.branding?.logo_url ? (
<Image source={{ uri: config.branding.logo_url }} style={{ width: 56, height: 56 }} contentFit="contain" />
) : (
<Feather name="lock" size={40} color={colors.primary} />
)}
</View>
<Text style={[styles.title, { color: colors.text }]}>{t('resetPass')}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
{t('resetSubtitle')} {config?.branding?.app_name || 'biiproject'}.
</Text>
</View>
{/* Card */}
<View style={[styles.card, { backgroundColor: cardBg, borderColor: border }]}>
{sent ? (
<View style={styles.successBox}>
<View style={[styles.successIcon, { backgroundColor: `${colors.primary}20` }]}>
<Feather name="check-circle" size={44} color={colors.primary} />
</View>
<Text style={[styles.successTitle, { color: colors.text }]}>{t('emailSentTitle')}</Text>
<Text style={[styles.successDesc, { color: colors.textSecondary }]}>
{t('emailSentDesc')}
</Text>
<AIButton
title={t('backToSignIn')}
onPress={() => router.back()}
style={{ width: '100%' }}
/>
</View>
) : (
<>
<AIInput
label={t('email')}
icon="mail"
placeholder="email@example.com"
value={values.email}
onChangeText={(v: string) => handleChange('email', v)}
keyboardType="email-address"
/>
<AIButton
title={t('sendInstructions')}
onPress={handleReset}
loading={loading}
style={{ marginTop: 12 }}
/>
<TouchableOpacity style={styles.cancelBtn} onPress={() => router.back()}>
<Text style={[styles.cancelText, { color: colors.textSecondary }]}>{t('cancel')}</Text>
</TouchableOpacity>
</>
)}
</View>
</KeyboardAwareScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
backBtn: { padding: 20, position: 'absolute', top: 0, left: 0, zIndex: 10 },
scroll: { paddingHorizontal: 24 },
header: { alignItems: 'center', marginBottom: 36 },
iconWrap: { width: 100, height: 100, borderRadius: 32, alignItems: 'center', justifyContent: 'center', elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.05, shadowRadius: 10 },
title: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', marginTop: 24 },
subtitle: { fontSize: 15, fontFamily: 'Outfit_400Regular', textAlign: 'center', marginTop: 10, paddingHorizontal: 20, lineHeight: 24 },
card: { borderRadius: 28, padding: 28, borderWidth: 1 },
cancelBtn: { alignItems: 'center', marginTop: 20 },
cancelText: { fontSize: 14, fontFamily: 'Outfit_600SemiBold' },
successBox: { alignItems: 'center', paddingVertical: 10 },
successIcon: { width: 88, height: 88, borderRadius: 44, alignItems: 'center', justifyContent: 'center', marginBottom: 24 },
successTitle: { fontSize: 24, fontFamily: 'Outfit_700Bold', marginBottom: 12 },
successDesc: { fontSize: 15, fontFamily: 'Outfit_400Regular', textAlign: 'center', marginBottom: 32, lineHeight: 24 },
});
+231
View File
@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react';
import {
View, Text, StyleSheet, Platform,
TouchableOpacity, ActivityIndicator,
} from 'react-native';
import { storage } from '../../utils/storage';
import * as LocalAuthentication from 'expo-local-authentication';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAuth } from '../../context/AuthContext';
import { useToast } from '../../context/ToastContext';
import { useForm } from '../../hooks/useForm';
import { useAppTheme } from '../../context/ThemeContext';
import { useAppConfig } from '../../context/ConfigContext';
import { useTranslation } from '../../context/LanguageContext';
import { Image } from 'expo-image';
import { AppScreen } from '../../components/AppScreen';
import { AIInput, AIButton } from '../../components/UI';
export default function LoginScreen() {
const router = useRouter();
const { signIn, isLoading } = useAuth();
const { showToast } = useToast();
const { colors, isDark } = useAppTheme();
const { config } = useAppConfig();
const { t } = useTranslation();
const { values, handleChange } = useForm({ email: '', password: '' });
const [bioCredentials, setBioCredentials] = useState<{ email: string; pass: string } | null>(null);
useEffect(() => { checkBiometrics(); }, []);
const checkBiometrics = async () => {
if (Platform.OS === 'web') return;
try {
const bioEnabled = await storage.get('pref_biometrics');
if (bioEnabled === 'true') {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (hasHardware && isEnrolled) {
const email = await storage.get('saved_email');
const pass = await storage.get('saved_pass');
if (email && pass) setBioCredentials({ email, pass });
}
}
} catch (e) { console.warn(e); }
};
const handleBiometricLogin = async () => {
if (!bioCredentials) return;
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: `${t('bioConfirm')} - ${config?.branding?.app_name || 'biiproject'}`,
fallbackLabel: t('password'),
});
if (result.success) {
await signIn(bioCredentials.email, bioCredentials.pass);
showToast(t('bioSuccess'), 'success');
router.replace('/(tabs)');
}
} catch { showToast(t('bioFailed'), 'error'); }
};
const handleLogin = async () => {
if (!values.email.includes('@')) { showToast(t('invalidEmail'), 'error'); return; }
try {
await signIn(values.email, values.password);
const bioEnabled = await storage.get('pref_biometrics');
if (bioEnabled === 'true') {
await storage.save('saved_email', values.email);
await storage.save('saved_pass', values.password);
}
showToast(`${t('welcomeBack')} ${config?.branding?.app_name || 'biiproject'}`, 'success');
router.replace('/(tabs)');
} catch (error: any) {
showToast(error.message || t('loginFailed'), 'error');
}
};
return (
<AppScreen scrollable={true}>
<View style={styles.scroll}>
{/* ── Header / Brand ── */}
<View style={styles.header}>
<View style={[styles.logoBox, { backgroundColor: colors.surface }]}>
{config?.branding?.logo_url ? (
<Image source={{ uri: config.branding.logo_url }} style={{ width: 52, height: 52 }} contentFit="contain" />
) : (
<Text style={[styles.logoLetter, { color: colors.primary }]}>B</Text>
)}
</View>
<Text style={[styles.brandName, { color: colors.text }]}>
{config?.security_auth?.login_title || 'biiproject'}
</Text>
<Text style={[styles.tagline, { color: colors.textSecondary }]}>
{config?.security_auth?.login_subtitle || t('registerSubtitle')}
</Text>
</View>
{/* ── Login card ── */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('signIn')}</Text>
{/* Email */}
<AIInput
label={t('email')}
icon="mail"
placeholder={t('emailPlaceholder')}
value={values.email}
onChangeText={(v: string) => handleChange('email', v)}
keyboardType="email-address"
/>
{/* Password */}
<AIInput
label={t('password')}
icon="lock"
placeholder={t('passwordPlaceholder')}
value={values.password}
onChangeText={(v: string) => handleChange('password', v)}
isPassword
/>
{/* Forgot */}
<TouchableOpacity onPress={() => router.push('/(auth)/forgot-password')} style={styles.forgotBtn}>
<Text style={[styles.forgotText, { color: colors.primary }]}>{t('forgotPass')}</Text>
</TouchableOpacity>
{/* Actions */}
<View style={styles.actionRow}>
<AIButton
title={t('signInNow')}
onPress={handleLogin}
loading={isLoading}
style={{ flex: 1, marginRight: bioCredentials ? 12 : 0 }}
/>
{bioCredentials && (
<TouchableOpacity
onPress={handleBiometricLogin}
style={[styles.bioBtn, { backgroundColor: isDark ? colors.surfaceLight : colors.background, borderColor: colors.border }]}
>
<MaterialCommunityIcons name="fingerprint" size={30} color={colors.primary} />
</TouchableOpacity>
)}
</View>
{/* Social logins */}
{(config?.security_auth?.oauth_google_enabled || config?.security_auth?.oauth_apple_enabled) && (
<View style={styles.socialSection}>
<View style={styles.dividerRow}>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textSecondary }]}>{t('orContinueWith')}</Text>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
</View>
<View style={styles.socialButtons}>
{config?.security_auth?.oauth_google_enabled && (
<TouchableOpacity style={[styles.socialBtn, { backgroundColor: colors.background, borderColor: colors.border }]}>
<MaterialCommunityIcons name="google" size={22} color={isDark ? '#FFF' : '#EA4335'} />
<Text style={[styles.socialText, { color: colors.text }]}>{t('google')}</Text>
</TouchableOpacity>
)}
{config?.security_auth?.oauth_apple_enabled && (
<TouchableOpacity style={[styles.socialBtn, { backgroundColor: colors.background, borderColor: colors.border }]}>
<MaterialCommunityIcons name="apple" size={22} color={colors.text} />
<Text style={[styles.socialText, { color: colors.text }]}>{t('apple')}</Text>
</TouchableOpacity>
)}
</View>
</View>
)}
{/* Register link */}
{config?.features?.enable_registration && (
<View style={styles.footer}>
<Text style={[styles.footerText, { color: colors.textSecondary }]}>{t('noAccount')}</Text>
<TouchableOpacity onPress={() => router.push('/(auth)/register')}>
<Text style={[styles.linkText, { color: colors.primary }]}>{t('signUp')}</Text>
</TouchableOpacity>
</View>
)}
</View>
<View style={{ height: 40 }} />
</View>
</AppScreen>
);
}
const styles = StyleSheet.create({
scroll: { paddingHorizontal: 24, paddingTop: 40 },
// Header
header: { alignItems: 'center', marginBottom: 28 },
logoBox: {
width: 80, height: 80, borderRadius: 26,
alignItems: 'center', justifyContent: 'center',
elevation: 12, shadowColor: '#000',
shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.1, shadowRadius: 18,
borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)',
},
logoLetter: { fontSize: 48, fontFamily: 'Outfit_800ExtraBold', lineHeight: 54 },
brandName: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', marginTop: 14, letterSpacing: -0.5 },
tagline: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
// Card
card: { borderRadius: 28, padding: 28, borderWidth: 1 },
cardTitle: { fontSize: 24, fontFamily: 'Outfit_700Bold', marginBottom: 22 },
// Actions
forgotBtn: { alignSelf: 'flex-end', marginTop: 12, marginBottom: 4 },
forgotText: { fontSize: 13, fontFamily: 'Outfit_600SemiBold' },
actionRow: { flexDirection: 'row', alignItems: 'center', marginTop: 22 },
bioBtn: { width: 56, height: 56, borderRadius: 16, alignItems: 'center', justifyContent: 'center', borderWidth: 1 },
// Social
socialSection: { marginTop: 26 },
dividerRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16 },
divider: { flex: 1, height: 1 },
dividerText: { marginHorizontal: 12, fontSize: 10, fontFamily: 'Outfit_700Bold', letterSpacing: 1 },
socialButtons: { flexDirection: 'row', gap: 10 },
socialBtn: {
flex: 1, height: 52, borderRadius: 14,
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: 1,
},
socialText: { marginLeft: 8, fontSize: 14, fontFamily: 'Outfit_600SemiBold' },
// Footer
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 24 },
footerText: { fontSize: 14, fontFamily: 'Outfit_400Regular' },
linkText: { fontSize: 14, fontFamily: 'Outfit_700Bold' },
});
+163
View File
@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import {
View, Text, StyleSheet, Platform,
TouchableOpacity, TextInput, ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Feather } from '@expo/vector-icons';
import { useAuth } from '../../context/AuthContext';
import { useToast } from '../../context/ToastContext';
import { useForm } from '../../hooks/useForm';
import { useAppTheme } from '../../context/ThemeContext';
import { useAppConfig } from '../../context/ConfigContext';
import { AppScreen } from '../../components/AppScreen';
import { AIButton, AIInput } from '../../components/UI';
import { Image } from 'expo-image';
import { useTranslation } from '../../context/LanguageContext';
export default function RegisterScreen() {
const router = useRouter();
const { signUp, isLoading } = useAuth();
const { showToast } = useToast();
const { colors, isDark } = useAppTheme();
const { config } = useAppConfig();
const { t } = useTranslation();
const { values, handleChange } = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const handleRegister = async () => {
if (!values.name || !values.email || !values.password) {
showToast(t('fillAll'), 'error');
return;
}
if (values.password !== values.password_confirmation) {
showToast(t('passMismatch'), 'error');
return;
}
try {
await signUp(values.name, values.email, values.password);
showToast(t('accountCreated'), 'success');
router.replace('/(tabs)');
} catch (error: any) {
showToast(error.message || t('regFailed'), 'error');
}
};
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
const inputBg = isDark ? '#222222' : '#F5F5F5';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<AppScreen scrollable={true}>
<View style={styles.scroll}>
{/* Header */}
<View style={styles.header}>
<View style={[styles.logoBox, { backgroundColor: isDark ? '#1A1A1A' : '#FFFFFF' }]}>
{config?.branding?.logo_url ? (
<Image source={{ uri: config.branding.logo_url }} style={{ width: 52, height: 52 }} contentFit="contain" />
) : (
<Text style={[styles.logoLetter, { color: colors.primary }]}>B</Text>
)}
</View>
<Text style={[styles.brandName, { color: colors.text }]}>{t('createAccount')}</Text>
<Text style={[styles.tagline, { color: colors.textSecondary }]}>
{t('join')} {config?.branding?.app_name || 'biiproject'} ecosystem
</Text>
</View>
{/* Form card */}
<View style={[styles.card, { backgroundColor: cardBg, borderColor: border }]}>
<AIInput
label={t('fullName')}
icon="user"
placeholder={t('namePlaceholder')}
value={values.name}
onChangeText={v => handleChange('name', v)}
/>
<AIInput
label={t('email')}
icon="mail"
placeholder={t('emailPlaceholder')}
value={values.email}
onChangeText={v => handleChange('email', v)}
autoCapitalize="none"
keyboardType="email-address"
containerStyle={{ marginTop: 14 }}
/>
<AIInput
label={t('password')}
icon="lock"
placeholder={t('passwordPlaceholder')}
value={values.password}
onChangeText={v => handleChange('password', v)}
isPassword
containerStyle={{ marginTop: 14 }}
/>
<AIInput
label={t('confirmPassword')}
icon="check-circle"
placeholder={t('passwordPlaceholder')}
value={values.password_confirmation}
onChangeText={v => handleChange('password_confirmation', v)}
isPassword
containerStyle={{ marginTop: 14 }}
/>
<AIButton
title={t('signUp')}
onPress={handleRegister}
loading={isLoading}
style={{ marginTop: 24 }}
/>
<View style={styles.footer}>
<Text style={[styles.footerText, { color: colors.textSecondary }]}>{t('haveAccount')}</Text>
<TouchableOpacity onPress={() => router.push('/(auth)/login')}>
<Text style={[styles.linkText, { color: colors.primary }]}>{t('signIn')}</Text>
</TouchableOpacity>
</View>
</View>
<View style={{ height: 40 }} />
</View>
</AppScreen>
);
}
const styles = StyleSheet.create({
scroll: { paddingHorizontal: 24, paddingTop: 40 },
header: { alignItems: 'center', marginBottom: 28 },
logoBox: {
width: 72, height: 72, borderRadius: 24,
alignItems: 'center', justifyContent: 'center',
elevation: 8, shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.1, shadowRadius: 12,
},
logoLetter: { fontSize: 40, fontFamily: 'Outfit_800ExtraBold' },
brandName: { fontSize: 28, fontFamily: 'Outfit_800ExtraBold', marginTop: 14 },
tagline: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
card: { borderRadius: 28, padding: 28, borderWidth: 1 },
inputGroup: {},
label: { fontSize: 10, fontFamily: 'Outfit_700Bold', letterSpacing: 1, marginBottom: 8, textTransform: 'uppercase' },
inputRow: {
flexDirection: 'row', alignItems: 'center',
height: 54, borderRadius: 14, borderWidth: 1, paddingHorizontal: 14,
},
inputIcon: { marginRight: 10 },
input: { flex: 1, fontSize: 15, fontFamily: 'Outfit_500Medium' },
mainBtn: { height: 56, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
btnText: { fontSize: 16, fontFamily: 'Outfit_700Bold' },
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 24 },
footerText: { fontSize: 14, fontFamily: 'Outfit_400Regular' },
linkText: { fontSize: 14, fontFamily: 'Outfit_700Bold' },
});
+112
View File
@@ -0,0 +1,112 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { StyleSheet, Platform, View } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../../context/ThemeContext';
import { useAppConfig } from '../../context/ConfigContext';
export default function TabLayout() {
const { colors, isDark } = useAppTheme();
const { config, syncConfig } = useAppConfig();
// Reference design: dark/charcoal tab bar with lime active, gray inactive
const tabBarBg = isDark ? '#111111' : '#FFFFFF';
const activeColor = isDark ? '#C6F135' : '#1A1A1A'; // lime on dark, black on light
const inactiveColor = isDark ? '#555555' : '#AAAAAA';
return (
<Tabs
screenListeners={{
state: () => {
syncConfig();
},
}}
screenOptions={{
tabBarActiveTintColor: activeColor,
tabBarInactiveTintColor: inactiveColor,
headerShown: false,
tabBarStyle: {
position: 'absolute',
borderTopWidth: 0,
backgroundColor: 'transparent',
elevation: 0,
height: 78,
paddingBottom: Platform.OS === 'ios' ? 22 : 12,
paddingTop: 10,
},
tabBarBackground: () => (
<View
style={[
StyleSheet.absoluteFill,
{
backgroundColor: tabBarBg,
borderTopWidth: 1,
borderTopColor: isDark ? '#2A2A2A' : '#EEEEEE',
}
]}
/>
),
tabBarLabelStyle: {
fontFamily: 'Outfit_600SemiBold',
fontSize: 11,
marginTop: 2,
},
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, focused }) => (
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
<Feather name="home" size={22} color={color} />
</View>
),
}}
/>
<Tabs.Screen
name="notifications"
options={{
title: 'Activity',
tabBarIcon: ({ color, focused }) => (
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
<Feather name="bell" size={22} color={color} />
</View>
),
}}
/>
<Tabs.Screen
name="help"
options={{
title: 'Support',
tabBarIcon: ({ color, focused }) => (
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
<Feather name="help-circle" size={22} color={color} />
</View>
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Profile',
tabBarIcon: ({ color, focused }) => (
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
<Feather name="user" size={22} color={color} />
</View>
),
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
activeIconWrap: {
width: 42,
height: 30,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
});
+389
View File
@@ -0,0 +1,389 @@
import React, { useState, useEffect } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity,
Image, Switch, Platform, ScrollView
} from 'react-native';
import { storage } from '../../utils/storage';
import * as LocalAuthentication from 'expo-local-authentication';
import * as ImagePicker from 'expo-image-picker';
import { Feather } from '@expo/vector-icons';
import { useAuth } from '../../context/AuthContext';
import { useAppTheme } from '../../context/ThemeContext';
import { useToast } from '../../context/ToastContext';
import { useAppConfig } from '../../context/ConfigContext';
import { AppScreen } from '../../components/AppScreen';
import { AIButton, AIInput, AISectionHeader, AIPressable, AISkeleton } from '../../components/UI';
import { ApiService } from '../../services/api';
import { Popup } from '../../components/Popup';
import { DebugLogger } from '../../utils/logger';
import { AISuccess } from '../../components/UI';
import { useTranslation } from '../../context/LanguageContext';
import * as Haptics from 'expo-haptics';
import { ActionTracker } from '../../utils/actionTracker';
export default function ProfileScreen() {
const { user, signOut, syncUser } = useAuth();
const { colors, isDark, setMode } = useAppTheme();
const { showToast } = useToast();
const { config } = useAppConfig();
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [editModalVisible, setEditModalVisible] = useState(false);
const [logoutConfirmVisible, setLogoutConfirmVisible] = useState(false);
const [tempName, setTempName] = useState(user?.name || config?.branding?.app_name || 'User');
const [tempAvatar, setTempAvatar] = useState(user?.avatar || config?.branding?.logo_url || `https://i.pravatar.cc/150?u=1`);
const [debugClicks, setDebugClicks] = useState(0);
const [logsModalVisible, setLogsModalVisible] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const [updateSuccess, setUpdateSuccess] = useState(false);
useEffect(() => {
// Track engagement for review prompt
ActionTracker.trackAction(
config?.features?.min_actions_before_review,
config?.features?.review_prompt_enabled
);
if (user) {
setTempName(user.name);
if (user.avatar) {
// Append timestamp to remote URL to bypass cache
const cacheBuster = user.avatar.includes('?') ? `&t=${Date.now()}` : `?t=${Date.now()}`;
setTempAvatar(`${user.avatar}${cacheBuster}`);
}
}
const timer = setTimeout(() => setLoading(false), 1200);
return () => clearTimeout(timer);
}, [user]);
const toggleTheme = () => {
const next = !isDark;
setMode(next ? 'dark' : 'light');
showToast(`${next ? 'Dark' : 'Light'} mode active`, 'info');
};
const handleLogout = () => {
setLogoutConfirmVisible(false);
showToast(t('logoutSafe'), 'info');
setTimeout(signOut, 1000);
};
const handlePickImage = async () => {
try {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
showToast('Permission to access gallery is required', 'error');
return;
}
const res = await ImagePicker.launchImageLibraryAsync({
allowsEditing: true,
aspect: [1, 1],
quality: 0.4,
});
if (!res.canceled && res.assets[0].uri) {
setLoading(true);
showToast(t('uploadingAvatar'), 'info');
await ApiService.updateAvatar(res.assets[0].uri);
await syncUser(); // Refresh global auth state
if (res.assets[0].uri) setTempAvatar(res.assets[0].uri);
showToast(t('avatarUpdated'), 'success');
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
} catch (error: any) {
const errorMsg = error.message || t('uploadFailed') || 'Upload failed';
showToast(`Error: ${errorMsg}`, 'error');
console.error('[AvatarUpload]', error);
DebugLogger.log(`Avatar upload error: ${errorMsg}`, 'error');
} finally {
setLoading(false);
}
};
const handleUpdateProfile = async () => {
if (!tempName.trim()) {
showToast('Name cannot be empty', 'error');
return;
}
setLoading(true);
try {
await ApiService.updateProfile(tempName, user?.email || '');
await syncUser(); // Refresh global data
setUpdateSuccess(true);
showToast(t('profileUpdated'), 'success');
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (error: any) {
showToast(error.message || t('updateFailed') || 'Update failed', 'error');
} finally {
setLoading(false);
}
};
const cardBg = colors.surface;
const border = colors.border;
const subText = colors.textSecondary;
const renderSkeleton = () => (
<View style={{ paddingHorizontal: 24, paddingTop: 56, alignItems: 'center' }}>
<AISkeleton width={100} height={100} radius={50} style={{ marginBottom: 20 }} />
<AISkeleton width={180} height={24} style={{ marginBottom: 10 }} />
<AISkeleton width={140} height={14} style={{ marginBottom: 32 }} />
<AISkeleton width="100%" height={240} radius={24} />
</View>
);
if (loading) return <AppScreen scrollable={false}>{renderSkeleton()}</AppScreen>;
return (
<AppScreen>
<View>
{/* ── Profile header ── */}
<View style={styles.header}>
<View style={styles.avatarWrap}>
<Image source={{ uri: tempAvatar }} style={[styles.avatar, { borderColor: border }]} />
<TouchableOpacity
style={[styles.camBtn, { backgroundColor: isDark ? colors.primary : '#1A1A1A' }]}
onPress={handlePickImage}
>
<Feather name="camera" size={14} color={isDark ? colors.secondary : colors.background} />
</TouchableOpacity>
</View>
<Text style={[styles.name, { color: colors.text }]}>{tempName}</Text>
<Text style={[styles.email, { color: subText }]}>
{user?.email || `user@${config?.branding?.app_name || 'biiproject'}.com`}
</Text>
<TouchableOpacity
style={[styles.editPill, { backgroundColor: cardBg, borderColor: border }]}
onPress={() => { setUpdateSuccess(false); setEditModalVisible(true); }}
>
<Feather name="edit-2" size={14} color={colors.text} />
<Text style={[styles.editPillText, { color: colors.text }]}>{t('editProfile')}</Text>
</TouchableOpacity>
</View>
{/* ── Settings section ── */}
<AISectionHeader title={t('preferences')} />
<View style={[styles.menuCard, { backgroundColor: cardBg, borderColor: border }]}>
<BiometricToggle t={t} />
<MenuRow
icon="moon"
label={t('darkTheme')}
rightContent={
<Switch
value={isDark}
onValueChange={toggleTheme}
trackColor={{ true: colors.primary, false: colors.border }}
thumbColor={colors.secondary}
/>
}
border={border}
/>
<MenuRow
icon="file-text"
label={t.privacyLink || "Privacy Policy"}
onPress={() => {
const url = config?.support_social?.privacy_policy_url || 'https://biiproject.com/privacy';
require('react-native').Linking.openURL(url);
}}
border={border}
/>
<MenuRow
icon="shield"
label={t.termsLink || "Terms of Service"}
onPress={() => {
const url = config?.support_social?.privacy_policy_url || 'https://biiproject.com/terms';
require('react-native').Linking.openURL(url);
}}
border={border}
isLast
/>
</View>
{/* ── Logout ── */}
<AIPressable onPress={() => setLogoutConfirmVisible(true)} style={styles.logoutPressable}>
<View style={[styles.logoutBtn, { borderColor: colors.error }]}>
<Feather name="log-out" size={18} color={colors.error} />
<Text style={[styles.logoutText, { color: colors.error }]}>{t('logout')}</Text>
</View>
</AIPressable>
{/* ── App Version (Hidden Debug Trigger) ── */}
<TouchableOpacity
activeOpacity={0.7}
onPress={() => {
const next = debugClicks + 1;
if (next >= 5) {
setLogs(DebugLogger.getLogs());
setLogsModalVisible(true);
setDebugClicks(0);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else {
setDebugClicks(next);
}
}}
style={styles.versionContainer}
>
<Text style={[styles.versionText, { color: subText }]}>
Version {config?.app_updates?.app_version || '2.0.0'} (Build 102)
</Text>
</TouchableOpacity>
<View style={{ height: 110 }} />
</View>
{/* ── Edit Profile Popup ── */}
<Popup visible={editModalVisible} onClose={() => { setEditModalVisible(false); setUpdateSuccess(false); }} title={t('editProfile')} type="bottom">
<View style={styles.popupBody}>
{updateSuccess ? (
<View style={{ alignItems: 'center', paddingVertical: 20 }}>
<AISuccess size={100} />
<Text style={[styles.successText, { color: colors.text }]}>{t('profileUpdated')}</Text>
<AIButton title={t('close') || "Great!"} onPress={() => { setEditModalVisible(false); setUpdateSuccess(false); }} style={{ width: '100%', marginTop: 20 }} />
</View>
) : (
<>
<AIInput label={t('fullName')} value={tempName} onChangeText={setTempName} icon="account-outline" />
<AIButton
title={t('confirmChanges') || "Save Changes"}
onPress={handleUpdateProfile}
loading={loading}
style={{ marginTop: 10 }}
/>
</>
)}
</View>
</Popup>
{/* ── Debug Logs Popup ── */}
<Popup visible={logsModalVisible} onClose={() => setLogsModalVisible(false)} title="System Logs" type="bottom">
<ScrollView style={{ maxHeight: 400 }}>
{logs.length === 0 ? (
<Text style={{ textAlign: 'center', padding: 20, color: '#888' }}>No logs recorded yet.</Text>
) : (
logs.map((log, i) => (
<View key={i} style={[styles.logRow, { borderBottomColor: border }]}>
<Text style={[styles.logText, { color: colors.text }]}>{log}</Text>
</View>
))
)}
<AIButton
title="Clear Logs"
color={colors.error}
onPress={() => { DebugLogger.clear(); setLogs([]); }}
style={{ marginTop: 20 }}
/>
</ScrollView>
</Popup>
{/* ── Logout Confirm Popup ── */}
<Popup visible={logoutConfirmVisible} onClose={() => setLogoutConfirmVisible(false)} title={t('logout')} type="center">
<View style={{ alignItems: 'center' }}>
<View style={[styles.logoutIcon, { backgroundColor: `${colors.error}20` }]}>
<Feather name="log-out" size={36} color={colors.error} />
</View>
<Text style={[styles.confirmDesc, { color: subText }]}>{t('confirmLogout')}</Text>
<View style={styles.confirmRow}>
<AIButton title={t('cancel')} color={colors.border} onPress={() => setLogoutConfirmVisible(false)} style={{ flex: 1 }} textStyle={{ color: colors.text }} />
<AIButton title={t('logout')} color={colors.error} onPress={handleLogout} style={{ flex: 1 }} />
</View>
</View>
</Popup>
</AppScreen>
);
}
function BiometricToggle({ t }: { t: any }) {
const { colors, isDark } = useAppTheme();
const { showToast } = useToast();
const [enabled, setEnabled] = useState(false);
useEffect(() => {
storage.get('pref_biometrics').then(v => setEnabled(v === 'true'));
}, []);
const toggle = async () => {
if (Platform.OS === 'web') {
showToast('Biometrics not available in browser', 'info');
return;
}
const res = await LocalAuthentication.authenticateAsync({ promptMessage: 'Verify identity' });
if (res.success) {
const next = !enabled;
setEnabled(next);
await storage.save('pref_biometrics', next ? 'true' : 'false');
showToast(`Biometrics ${next ? 'enabled' : 'disabled'}`, 'success');
}
};
const border = colors.border;
return (
<MenuRow
icon="shield"
label={t.biometrics || "Biometric Login"}
rightContent={
<Switch
value={enabled}
onValueChange={toggle}
trackColor={{ true: colors.primary, false: '#333' }}
thumbColor={enabled ? '#FFFFFF' : '#FFFFFF'}
/>
}
border={border}
/>
);
}
function MenuRow({ icon, label, rightContent, onPress, border, isLast }: any) {
const { colors, isDark } = useAppTheme();
const Wrapper: any = onPress ? TouchableOpacity : View;
return (
<Wrapper onPress={onPress} style={[styles.menuRow, { borderBottomColor: border, borderBottomWidth: isLast ? 0 : 1 }]}>
<View style={styles.menuLeft}>
<View style={[styles.menuIconBox, { backgroundColor: colors.background }]}>
<Feather name={icon} size={16} color={isDark ? colors.primary : colors.secondary} />
</View>
<Text style={[styles.menuLabel, { color: colors.text }]}>{label}</Text>
</View>
{rightContent || <Feather name="chevron-right" size={18} color={colors.textPlaceholder} />}
</Wrapper>
);
}
const styles = StyleSheet.create({
header: { alignItems: 'center', paddingTop: 20, paddingBottom: 28, paddingHorizontal: 24 },
avatarWrap: { position: 'relative', marginBottom: 16 },
avatar: { width: 100, height: 100, borderRadius: 50, borderWidth: 3 },
camBtn: { position: 'absolute', bottom: 4, right: 4, width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', elevation: 4 },
name: { fontSize: 26, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
email: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
editPill: { flexDirection: 'row', alignItems: 'center', marginTop: 20, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 14, borderWidth: 1, gap: 8 },
editPillText: { fontSize: 13, fontFamily: 'Outfit_700Bold' },
menuCard: { marginHorizontal: 24, borderRadius: 24, borderWidth: 1, overflow: 'hidden', marginBottom: 16 },
menuRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 16 },
menuLeft: { flexDirection: 'row', alignItems: 'center', gap: 14 },
menuIconBox: { width: 36, height: 36, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
menuLabel: { fontSize: 15, fontFamily: 'Outfit_600SemiBold' },
logoutPressable: { marginHorizontal: 24, marginTop: 12 },
logoutBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 16, borderRadius: 20, borderWidth: 1.5, borderStyle: 'dashed', gap: 10 },
logoutText: { fontSize: 15, fontFamily: 'Outfit_700Bold' },
popupBody: { paddingTop: 10 },
logoutIcon: { width: 80, height: 80, borderRadius: 24, alignItems: 'center', justifyContent: 'center', marginBottom: 16 },
confirmDesc: { fontSize: 15, fontFamily: 'Outfit_400Regular', textAlign: 'center', marginBottom: 28 },
confirmRow: { flexDirection: 'row', gap: 12, width: '100%' },
successText: { fontSize: 18, fontFamily: 'Outfit_700Bold', marginTop: 12 },
versionContainer: { alignItems: 'center', marginTop: 30, paddingVertical: 10 },
versionText: { fontSize: 11, fontFamily: 'Outfit_500Medium', opacity: 0.6 },
logRow: { paddingVertical: 10, borderBottomWidth: 1 },
logText: { fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
});
+207
View File
@@ -0,0 +1,207 @@
import React, { useState, useEffect, useMemo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ScrollView, Platform } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../../context/ThemeContext';
import { useToast } from '../../context/ToastContext';
import { useAppConfig } from '../../context/ConfigContext';
import { AppScreen } from '../../components/AppScreen';
import { AISectionHeader, AISkeleton, AIPressable } from '../../components/UI';
import { useTranslation } from '../../context/LanguageContext';
import { MOCK_FAQS } from '../../constants/mocks';
import { PALETTE } from '../../constants/theme';
import * as Haptics from 'expo-haptics';
const getTopics = (t: any) => [
{ id: '1', name: t.web || 'Web', icon: 'book-open' },
{ id: '2', name: t.account || 'Account', icon: 'user' },
{ id: '3', name: t.billing || 'Billing', icon: 'credit-card' },
{ id: '4', name: t.system || 'System', icon: 'cpu' },
];
// Mock data moved to constants/mocks.ts
export default function HelpScreen() {
const { colors, isDark } = useAppTheme();
const { showToast } = useToast();
const { config } = useAppConfig();
const { t } = useTranslation();
const topics = useMemo(() => {
if (config?.support_social?.help_topics_json && Array.isArray(config.support_social.help_topics_json)) {
return config.support_social.help_topics_json;
}
return getTopics(t);
}, [config?.support_social?.help_topics_json, t]);
const faqs = useMemo(() => {
if (config?.support_social?.faq_json && Array.isArray(config.support_social.faq_json)) {
return config.support_social.faq_json.map((f, i) => ({ id: String(i+1), ...f }));
}
return MOCK_FAQS;
}, [config?.support_social?.faq_json]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 1500);
return () => clearTimeout(timer);
}, []);
const handleContactSupport = (type: 'whatsapp' | 'email') => {
const contact = type === 'whatsapp' ? config?.support_social?.support_whatsapp : config?.support_social?.support_email;
if (contact) {
const url = type === 'whatsapp' ? `https://wa.me/${contact}` : `mailto:${contact}`;
require('react-native').Linking.openURL(url).catch(() => {
showToast(`Failed to open ${type}`, 'error');
});
} else {
showToast('Support contact not available', 'info');
}
};
const cardBg = colors.surface;
const border = colors.border;
const subText = colors.textSecondary;
const renderTopic = (topic: any) => {
return (
<AIPressable
key={topic.id}
onPress={() => showToast(`Opening ${topic.name} topics`, 'info')}
style={styles.topicWrapper}
containerStyle={{ flex: 1 }}
>
<View style={[styles.topicCard, { backgroundColor: cardBg, borderColor: border }]}>
<Feather name={topic.icon as any} size={24} color={isDark ? colors.primary : colors.secondary} />
<Text style={[styles.topicName, { color: colors.text }]} numberOfLines={1}>{topic.name}</Text>
</View>
</AIPressable>
);
};
const renderSkeleton = () => (
<View style={{ paddingHorizontal: 24, paddingTop: 56 }}>
<AISkeleton width={220} height={32} style={{ marginBottom: 12 }} />
<AISkeleton width={160} height={14} style={{ marginBottom: 32 }} />
<AISkeleton width="100%" height={56} radius={14} style={{ marginBottom: 32 }} />
<View style={{ flexDirection: 'row', gap: 12 }}>
{[1, 2, 3, 4].map(i => <AISkeleton key={i} width="22%" height={90} radius={20} />)}
</View>
</View>
);
if (loading) return <AppScreen scrollable={false}>{renderSkeleton()}</AppScreen>;
return (
<AppScreen>
<View>
{/* Header */}
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>{t.supportCenter || 'Support Center'}</Text>
<Text style={[styles.subtitle, { color: subText }]}>
{t.helpSubtitle || 'Find answers'}
</Text>
</View>
{/* Search */}
<View style={styles.searchSection}>
<View style={[styles.searchBox, { backgroundColor: cardBg, borderColor: border }]}>
<Feather name="search" size={18} color={colors.textPlaceholder} />
<TextInput
placeholder={t.searchDoc || "Search documentation..."}
placeholderTextColor={isDark ? '#444' : '#BBBBBB'}
style={[styles.searchInput, { color: colors.text }]}
value={search}
onChangeText={setSearch}
/>
</View>
</View>
{/* Topics Grid: 2 Columns FULL */}
<AISectionHeader title={t.browseTopics || "Browse Topics"} />
<View style={styles.topicGrid}>
<View style={styles.topicRow}>
{renderTopic(topics[0])}
{renderTopic(topics[1])}
</View>
<View style={styles.topicRow}>
{renderTopic(topics[2])}
{renderTopic(topics[3])}
</View>
</View>
{/* FAQs */}
<AISectionHeader title={t.faqTitle || "Frequently Asked Questions (FAQ)"} />
<View style={styles.faqList}>
{faqs.map((faq: any) => (
<AIPressable key={faq.id} style={[styles.faqCard, { backgroundColor: cardBg, borderColor: border }]}>
<View style={styles.faqRow}>
<View style={styles.faqIconBox}>
<Feather name="help-circle" size={18} color={colors.primary} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.question, { color: colors.text }]}>{faq.q}</Text>
<Text style={[styles.answer, { color: subText }]}>{faq.a}</Text>
</View>
<Feather name="chevron-right" size={16} color={subText} />
</View>
</AIPressable>
))}
</View>
{/* Contact Footer */}
<View style={styles.footerRow}>
<AIPressable style={styles.supportBtn} onPress={() => handleContactSupport('whatsapp')}>
<View style={[styles.contactCard, { backgroundColor: '#1A1A1A' }]}>
<View style={[styles.contactIcon, { backgroundColor: '#25D36620' }]}>
<Feather name="message-circle" size={20} color="#25D366" />
</View>
<Text style={styles.contactLabel}>{t.whatsapp || 'WhatsApp'}</Text>
</View>
</AIPressable>
<AIPressable style={styles.supportBtn} onPress={() => handleContactSupport('email')}>
<View style={[styles.contactCard, { backgroundColor: '#1A1A1A' }]}>
<View style={[styles.contactIcon, { backgroundColor: colors.primary + '20' }]}>
<Feather name="mail" size={20} color={colors.primary} />
</View>
<Text style={styles.contactLabel}>{t.emailSupport || 'Email Support'}</Text>
</View>
</AIPressable>
</View>
<View style={{ height: 110 }} />
</View>
</AppScreen>
);
}
const styles = StyleSheet.create({
header: { paddingHorizontal: 24, paddingTop: 20, marginBottom: 20 },
title: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
subtitle: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
searchSection: { paddingHorizontal: 24, marginBottom: 28 },
searchBox: { flexDirection: 'row', alignItems: 'center', height: 56, borderRadius: 16, borderWidth: 1, paddingHorizontal: 16, gap: 12 },
searchInput: { flex: 1, fontSize: 15, fontFamily: 'Outfit_500Medium' },
topicGrid: { paddingHorizontal: 24, gap: 12, marginBottom: 24 },
topicRow: { flexDirection: 'row', gap: 12 },
topicWrapper: { flex: 1 },
topicCard: { height: 100, borderRadius: 20, borderWidth: 1, alignItems: 'center', justifyContent: 'center', gap: 10 },
topicName: { fontSize: 13, fontFamily: 'Outfit_700Bold' },
faqList: { paddingHorizontal: 24, gap: 12 },
faqCard: { borderRadius: 24, borderWidth: 1, padding: 16 },
faqRow: { flexDirection: 'row', alignItems: 'center', gap: 14 },
faqIconBox: { width: 36, height: 36, borderRadius: 10, backgroundColor: '#C6F13520', alignItems: 'center', justifyContent: 'center' },
question: { fontSize: 14, fontFamily: 'Outfit_700Bold', marginBottom: 2 },
answer: { fontSize: 12, fontFamily: 'Outfit_400Regular', lineHeight: 18 },
footerRow: { flexDirection: 'row', paddingHorizontal: 24, gap: 12, marginTop: 32 },
supportBtn: { flex: 1 },
contactCard: { height: 120, borderRadius: 24, alignItems: 'center', justifyContent: 'center', gap: 12 },
contactIcon: { width: 44, height: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center' },
contactLabel: { color: '#FFFFFF', fontSize: 13, fontFamily: 'Outfit_700Bold' },
});
+250
View File
@@ -0,0 +1,250 @@
import React, { useState, useMemo, useEffect } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, Image,
FlatList, Platform, StatusBar, Dimensions
} from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useAuth } from '../../context/AuthContext';
import { useAppTheme } from '../../context/ThemeContext';
import { useToast } from '../../context/ToastContext';
import { AppScreen } from '../../components/AppScreen';
import { AISectionHeader, AISkeleton, AIPressable } from '../../components/UI';
import { useTranslation } from '../../context/LanguageContext';
import { useAppConfig } from '../../context/ConfigContext';
import { MOCK_ARTICLES } from '../../constants/mocks';
import * as Haptics from 'expo-haptics';
const getQuickActions = (t: any) => {
return [
{ id: '1', name: t('account') || 'Account', icon: 'user', dark: true },
{ id: '2', name: t('subscription') || 'Subscription', icon: 'credit-card', dark: false },
{ id: '3', name: t('system') || 'System', icon: 'cpu', dark: true },
{ id: '4', name: t('explore') || 'Explore', icon: 'compass', dark: false },
];
};
const getCategories = (t: any) => [
{ id: '1', name: t('all') || 'All' },
{ id: '2', name: 'LLM' },
{ id: '3', name: 'Robotics' },
{ id: '4', name: 'Health' },
{ id: '5', name: 'Coding' },
];
// Mock data moved to constants/mocks.ts
export default function Dashboard() {
const { user } = useAuth();
const { colors, isDark } = useAppTheme();
const { showToast } = useToast();
const { t } = useTranslation();
const { config } = useAppConfig();
const router = useRouter();
const quickActions = useMemo(() => getQuickActions(t), [t]);
const [loading, setLoading] = useState(true);
const categories = useMemo(() => {
if (config?.features?.dashboard_categories) {
return config.features.dashboard_categories.split(',').map((name, index) => ({
id: String(index + 1),
name: name.trim()
}));
}
return getCategories(t);
}, [config?.features?.dashboard_categories, t]);
const [activeCategory, setActiveCategory] = useState(t('all') || 'All');
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 1500);
return () => clearTimeout(timer);
}, []);
const handleAction = (name: string) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
showToast(`Opening ${name}`, 'info');
};
const filteredArticles = useMemo(
() => MOCK_ARTICLES.filter(a => activeCategory === 'All' || a.category === activeCategory),
[activeCategory]
);
const cardBg = colors.surface;
const cardBorder = colors.border;
const subText = colors.textSecondary;
const renderHeader = () => (
<View style={styles.headerContent}>
{/* ── Greeting row ── */}
<View style={styles.headerTop}>
<View style={{ flex: 1 }}>
<Text style={[styles.greeting, { color: colors.textSecondary }]}>{t('halo') || 'Good morning'} 👋</Text>
<Text style={[styles.welcomeText, { color: colors.text }]}>{(user?.name || 'Alex').split(' ')[0]}</Text>
</View>
<TouchableOpacity onPress={() => router.push('/(tabs)/explore')}>
<Image
source={{ uri: user?.avatar || `https://i.pravatar.cc/150?u=1` }}
style={[styles.avatar, { borderColor: colors.primary }]}
/>
</TouchableOpacity>
</View>
{/* ── Highlight card ── */}
<AIPressable onPress={() => handleAction(t('getHelp') || 'Support')} style={styles.highlightPressable}>
<View style={[styles.highlightCard, { backgroundColor: '#1A1A1A' }]}>
<View style={{ flex: 1 }}>
<Text style={styles.highlightLabel}>{t('systemSupport') || 'System Support'}</Text>
<Text style={styles.highlightValue}>{t('instantHelp') || 'Instant Help 24/7'}</Text>
<View style={[styles.limeBtn, { backgroundColor: colors.primary }]}>
<Text style={styles.limeBtnText}>{t('getHelp') || 'Get Help'}</Text>
<Feather name="arrow-right" size={14} color="#1A1A1A" style={{ marginLeft: 6 }} />
</View>
</View>
<View style={[styles.highlightIcon, { backgroundColor: colors.primary + '20' }]}>
<Feather name="shield" size={38} color={colors.primary} />
</View>
</View>
</AIPressable>
{/* ── Quick action grid ── */}
<AISectionHeader title={t('quickActions') || "Quick Actions"} />
<View style={styles.quickGrid}>
<View style={styles.actionRow}>
{renderAction(quickActions[0], false)}
{renderAction(quickActions[1], true)}
</View>
<View style={styles.actionRow}>
{renderAction(quickActions[2], true)}
{renderAction(quickActions[3], false)}
</View>
</View>
{/* ── Categories ── */}
<AISectionHeader title={t('categories') || "Categories"} />
<FlatList
data={categories}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoryList}
keyExtractor={item => item.id}
renderItem={({ item }) => {
const isActive = activeCategory === item.name;
return (
<TouchableOpacity
onPress={() => {
Haptics.selectionAsync();
setActiveCategory(item.name);
}}
style={[
styles.categoryPill,
{
backgroundColor: isActive ? (isDark ? colors.primary : '#1A1A1A') : (isDark ? '#2A2A2A' : '#FFFFFF'),
borderColor: isActive ? 'transparent' : (isDark ? '#3A3A3C' : '#EEEEEE'),
}
]}
>
<Text style={[styles.categoryText, { color: isActive ? (isDark ? '#1A1A1A' : '#FFFFFF') : (isDark ? '#9B9B9B' : '#6B6B6B') }]}>
{item.name}
</Text>
</TouchableOpacity>
);
}}
/>
<AISectionHeader title={t('latestDiscoveries') || "Latest Discoveries"} style={{ marginTop: 20 }} />
</View>
);
const renderAction = (item: any, isDarkCard: boolean) => {
const bg = isDarkCard ? (isDark ? '#2A2A2A' : '#1A1A1A') : colors.primary;
const iconColor = isDarkCard ? (isDark ? colors.primary : '#FFFFFF') : '#1A1A1A';
const textColor = isDarkCard ? '#FFFFFF' : '#1A1A1A';
return (
<AIPressable
key={item.id}
onPress={() => handleAction(item.name)}
style={styles.actionCardWrapper}
containerStyle={styles.actionCardInner}
>
<View style={[styles.innerContent, { backgroundColor: bg, borderColor: isDark ? '#333' : 'transparent' }]}>
<Feather name={item.icon} size={24} color={iconColor} />
<Text style={[styles.actionName, { color: textColor }]} numberOfLines={1}>{item.name}</Text>
</View>
</AIPressable>
);
};
return (
<AppScreen scrollable={false}>
{loading ? (
<View style={{ padding: 24 }}><AISkeleton width="100%" height={200} radius={24} /></View>
) : (
<FlatList
data={filteredArticles}
keyExtractor={item => item.id}
ListHeaderComponent={renderHeader}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
renderItem={({ item }) => (
<AIPressable
onPress={() => router.push({ pathname: '/detail/[id]' as any, params: { ...item, id: item.id } })}
style={styles.feedPressable}
>
<View style={[styles.feedCardInner, { backgroundColor: cardBg, borderColor: cardBorder }]}>
<Image source={{ uri: item.img }} style={styles.cardImg} />
<View style={{ flex: 1, marginLeft: 14 }}>
<View style={[styles.cardCatWrap, { backgroundColor: colors.primary + '20' }]}><Text style={[styles.cardCat, { color: colors.primary }]}>{item.category}</Text></View>
<Text style={[styles.cardTitle, { color: colors.text }]} numberOfLines={2}>{item.title}</Text>
<Text style={[styles.cardAuthor, { color: subText }]}>{item.author}</Text>
</View>
<Feather name="chevron-right" size={18} color={isDark ? '#444' : '#CCCCCC'} />
</View>
</AIPressable>
)}
ListFooterComponent={<View style={{ height: 100 }} />}
/>
)}
</AppScreen>
);
}
const styles = StyleSheet.create({
scrollContent: { paddingBottom: 20 },
headerContent: { paddingTop: 10 },
headerTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 24, marginBottom: 20 },
greeting: { fontSize: 13, fontFamily: 'Outfit_400Regular' },
welcomeText: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', marginTop: 2 },
avatar: { width: 48, height: 48, borderRadius: 24, borderWidth: 2.5 },
highlightPressable: { marginHorizontal: 24, marginBottom: 24 },
highlightCard: { borderRadius: 24, padding: 22, flexDirection: 'row', alignItems: 'center', elevation: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.15, shadowRadius: 20 },
highlightLabel: { color: '#6B6B6B', fontFamily: 'Outfit_500Medium', fontSize: 11, textTransform: 'uppercase' },
highlightValue: { color: '#FFFFFF', fontFamily: 'Outfit_800ExtraBold', fontSize: 22, marginTop: 4, marginBottom: 16 },
limeBtn: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 12 },
limeBtnText: { color: '#1A1A1A', fontFamily: 'Outfit_700Bold', fontSize: 13 },
highlightIcon: { width: 68, height: 68, borderRadius: 20, alignItems: 'center', justifyContent: 'center', marginLeft: 16 },
quickGrid: { paddingHorizontal: 24, gap: 12 },
actionRow: { flexDirection: 'row', gap: 12, marginBottom: 12 },
actionCardWrapper: { flex: 1 },
actionCardInner: { flex: 1 },
innerContent: { height: 94, borderRadius: 20, borderWidth: 1, padding: 16, justifyContent: 'space-between' },
actionName: { fontSize: 14, fontFamily: 'Outfit_700Bold' },
categoryList: { paddingHorizontal: 24, paddingBottom: 4 },
categoryPill: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 12, marginRight: 10, borderWidth: 1 },
categoryText: { fontSize: 13, fontFamily: 'Outfit_600SemiBold' },
feedPressable: { marginHorizontal: 24, marginBottom: 10 },
feedCardInner: { flexDirection: 'row', padding: 12, alignItems: 'center', borderRadius: 20, borderWidth: 1 },
cardImg: { width: 70, height: 70, borderRadius: 14 },
cardCatWrap: { alignSelf: 'flex-start', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, marginBottom: 6 },
cardCat: { fontSize: 9, fontFamily: 'Outfit_800ExtraBold', textTransform: 'uppercase' },
cardTitle: { fontSize: 14, fontFamily: 'Outfit_700Bold', lineHeight: 18 },
cardAuthor: { fontSize: 11, fontFamily: 'Outfit_400Regular', marginTop: 4 },
});
+132
View File
@@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, ScrollView, Platform } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../../context/ThemeContext';
import { AppScreen } from '../../components/AppScreen';
import { AISectionHeader, AISkeleton, AIPressable } from '../../components/UI';
import { useTranslation } from '../../context/LanguageContext';
import { MOCK_NOTIFICATIONS } from '../../constants/mocks';
import { PALETTE } from '../../constants/theme';
import * as Haptics from 'expo-haptics';
const LIME = PALETTE.lime;
const TYPE_MAP: Record<string, { icon: any; color: string }> = {
success: { icon: 'check-circle', color: LIME },
info: { icon: 'info', color: '#3B82F6' },
warning: { icon: 'alert-circle', color: '#F59E0B' },
alert: { icon: 'shield', color: '#EF4444' },
update: { icon: 'refresh-cw', color: '#8B5CF6' },
};
// Mock data moved to constants/mocks.ts
export default function NotificationsScreen() {
const { colors, isDark } = useAppTheme();
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 1500);
return () => clearTimeout(timer);
}, []);
const cardBg = colors.surface;
const border = colors.border;
const subText = colors.textSecondary;
const renderSkeleton = () => (
<View style={{ paddingHorizontal: 24, paddingTop: 56 }}>
<AISkeleton width={140} height={32} style={{ marginBottom: 10 }} />
<AISkeleton width={180} height={14} style={{ marginBottom: 32 }} />
{[1, 2, 3, 4].map(i => (
<View key={i} style={{ flexDirection: 'row', marginBottom: 12, gap: 14 }}>
<AISkeleton width={48} height={48} radius={14} />
<View style={{ flex: 1, justifyContent: 'center' }}>
<AISkeleton width="70%" height={14} style={{ marginBottom: 8 }} />
<AISkeleton width="40%" height={10} />
</View>
</View>
))}
</View>
);
if (loading) return <AppScreen scrollable={false}>{renderSkeleton()}</AppScreen>;
return (
<AppScreen>
<View>
{/* Header */}
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>{t.notifications || 'Activity'}</Text>
<Text style={[styles.subtitle, { color: subText }]}>
{MOCK_NOTIFICATIONS.length} {t.recentNotifications || 'recent notifications'}
</Text>
</View>
{/* List */}
<View style={styles.list}>
{MOCK_NOTIFICATIONS.map((item, index) => {
const meta = TYPE_MAP[item.type] || TYPE_MAP.info;
return (
<AIPressable
key={item.id}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}}
style={styles.notifPressable}
>
<View style={[styles.card, { backgroundColor: cardBg, borderColor: border }]}>
{/* Left: colored icon */}
<View style={[styles.iconBox, { backgroundColor: `${meta.color}18` }]}>
<Feather name={meta.icon} size={22} color={meta.color} />
</View>
{/* Body */}
<View style={styles.body}>
<View style={styles.topRow}>
<Text style={[styles.notifTitle, { color: colors.text }]} numberOfLines={1}>
{item.title}
</Text>
<Text style={[styles.time, { color: subText }]}>{item.time}</Text>
</View>
<Text style={[styles.desc, { color: subText }]} numberOfLines={2}>
{item.desc}
</Text>
</View>
</View>
</AIPressable>
);
})}
</View>
<View style={{ height: 110 }} />
</View>
</AppScreen>
);
}
const styles = StyleSheet.create({
header: { paddingHorizontal: 24, paddingTop: 20, marginBottom: 22 },
title: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
subtitle: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
list: { paddingHorizontal: 24 },
notifPressable: { marginBottom: 10 },
card: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
borderRadius: 20,
borderWidth: 1,
},
iconBox: {
width: 48, height: 48, borderRadius: 14,
alignItems: 'center', justifyContent: 'center',
marginRight: 14, flexShrink: 0,
},
body: { flex: 1 },
topRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4 },
notifTitle: { fontSize: 15, fontFamily: 'Outfit_700Bold', flex: 1, marginRight: 8 },
time: { fontSize: 11, fontFamily: 'Outfit_500Medium', flexShrink: 0, marginTop: 1 },
desc: { fontSize: 13, fontFamily: 'Outfit_400Regular', lineHeight: 18 },
});
+193
View File
@@ -0,0 +1,193 @@
import { Stack } from 'expo-router';
import { ThemeProvider, useAppTheme } from '../context/ThemeContext';
import { AuthProvider, useAuth } from '../context/AuthContext';
import { ToastProvider } from '../context/ToastContext';
import { RefreshProvider, useRefresh } from '../context/RefreshContext';
import { ConfigProvider, useAppConfig } from '../context/ConfigContext';
import { LanguageProvider } from '../context/LanguageContext';
import { View, Platform, StyleSheet, TouchableOpacity, Text, ActivityIndicator, StatusBar } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useEffect, useState } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import { useFonts, Outfit_300Light, Outfit_400Regular, Outfit_500Medium, Outfit_600SemiBold, Outfit_700Bold, Outfit_800ExtraBold } from '@expo-google-fonts/outfit';
import { AnimatedSplash } from '../components/AnimatedSplash';
import { ErrorBoundary } from '../components/ErrorBoundary';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [fontsLoaded, fontError] = useFonts({
Outfit_300Light,
Outfit_400Regular,
Outfit_500Medium,
Outfit_600SemiBold,
Outfit_700Bold,
Outfit_800ExtraBold,
});
const [isAnimationComplete, setIsAnimationComplete] = useState(false);
useEffect(() => {
if (fontsLoaded || fontError) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, fontError]);
if (!fontsLoaded && !fontError) return <View style={{ flex: 1, backgroundColor: '#020617' }} />;
return (
<ErrorBoundary>
<ConfigProvider>
<ThemeProvider>
<LanguageProvider>
<AuthProvider>
<RefreshProvider>
<ToastProvider>
{!isAnimationComplete ? (
<AnimatedSplash key="splash-screen" onAnimationComplete={() => setIsAnimationComplete(true)} />
) : (
<RootLayoutContent key="main-app-content" />
)}
</ToastProvider>
</RefreshProvider>
</AuthProvider>
</LanguageProvider>
</ThemeProvider>
</ConfigProvider>
</ErrorBoundary>
);
}
import { KillSwitchOverlay } from '../components/KillSwitchOverlay';
import { AnnouncementBanner } from '../components/AnnouncementBanner';
function RootLayoutContent() {
// Safe hook call because it's now guaranteed to be rendered ONLY when providers are ready and animation is done
const { colors } = useAppTheme();
const { config, isConnected, isSyncing, syncConfig } = useAppConfig();
const [announcementDismissed, setAnnouncementDismissed] = useState(false);
useEffect(() => {
if (isConnected) syncConfig();
}, [isConnected]);
// Logic for Kill Switch & Maintenance
const isKillSwitchActive = config?.control_center?.kill_switch_active || false;
// Calculate if maintenance is currently active
const isInMaintenanceWindow = () => {
const start = config?.control_center?.maintenance_start_at;
const end = config?.control_center?.maintenance_end_at;
if (!start || !end) return false;
const now = new Date().getTime();
const startTime = new Date(start).getTime();
const endTime = new Date(end).getTime();
return now >= startTime && now <= endTime;
};
const shouldBlockAccess = isKillSwitchActive || isInMaintenanceWindow();
// Logic for Force Update — current version comes from synced config so admin can advertise the released build
const currentAppVersion = config?.app_updates?.app_version || "2.0.0";
const minVersion = config?.app_updates?.min_app_version || "1.0.0";
const isUpdateRequired = () => {
if (!minVersion) return false;
const current = currentAppVersion.split('.').map(Number);
const min = minVersion.split('.').map(Number);
for (let i = 0; i < Math.max(current.length, min.length); i++) {
const v1 = current[i] || 0;
const v2 = min[i] || 0;
if (v1 < v2) return true;
if (v1 > v2) return false;
}
return false;
};
const isUpdating = isUpdateRequired();
const killSwitchMessage = isKillSwitchActive
? config?.control_center?.kill_switch_message
: (isInMaintenanceWindow() ? "System is currently undergoing scheduled maintenance." : "");
const updateMessage = `A mandatory update (v${minVersion}) is required to continue using the app. Please update from the store.`;
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<StatusBar barStyle={Platform.OS === 'ios' ? 'dark-content' : 'default'} />
{/* 1. Global Announcement */}
<AnnouncementBanner
visible={!!config?.control_center?.announcement_enabled && !announcementDismissed && !shouldBlockAccess}
message={config?.control_center?.announcement_text || ''}
type={config?.control_center?.announcement_type}
onClose={() => setAnnouncementDismissed(true)}
/>
{/* 2. Kill Switch / Maintenance Overlay */}
<KillSwitchOverlay
visible={shouldBlockAccess}
message={killSwitchMessage}
supportEmail={config?.support_social?.support_email}
/>
{/* 3. Force Update Overlay */}
<KillSwitchOverlay
visible={isUpdating && !shouldBlockAccess}
message={updateMessage}
/>
{/* Offline Indicator */}
{!isConnected && (
<View style={[styles.offlineBanner, { backgroundColor: colors.error }]}>
<Feather name="wifi-off" size={14} color="#FFF" />
<Text style={styles.offlineText}>You are currently offline.</Text>
</View>
)}
{/* Sync Indicator */}
{isSyncing && isConnected && (
<View style={styles.syncIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
)}
<Stack screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
animation: 'fade'
}}>
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
<Stack.Screen name="(tabs)" options={{ animation: 'fade' }} />
</Stack>
</View>
);
}
const styles = StyleSheet.create({
offlineBanner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
gap: 8,
zIndex: 999,
},
offlineText: {
color: '#FFF',
fontSize: 12,
fontFamily: 'Outfit_600SemiBold',
},
syncIndicator: {
position: 'absolute',
top: Platform.OS === 'ios' ? 60 : 40,
right: 20,
zIndex: 1000,
backgroundColor: 'rgba(0,0,0,0.05)',
borderRadius: 20,
padding: 4,
}
});
+128
View File
@@ -0,0 +1,128 @@
import React, { useRef } from 'react';
import {
View, Text, StyleSheet, Image, TouchableOpacity, Dimensions, ScrollView, Animated, Platform
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useAppTheme } from '../../context/ThemeContext';
import { Feather } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import * as Haptics from 'expo-haptics';
import { LinearGradient } from 'expo-linear-gradient';
const { width } = Dimensions.get('window');
const LIME = '#C6F135';
const IMG_H = 340;
export default function DetailScreen() {
const { id, title, category, img, author } = useLocalSearchParams();
const { colors, isDark } = useAppTheme();
const router = useRouter();
const scrollY = useRef(new Animated.Value(0)).current;
const bg = isDark ? '#111111' : '#F5F5F5';
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
const subText = isDark ? '#6B6B6B' : '#9B9B9B';
const imageTranslateY = scrollY.interpolate({
inputRange: [0, IMG_H],
outputRange: [0, -IMG_H / 3],
extrapolate: 'clamp'
});
return (
<View style={[styles.container, { backgroundColor: bg }]}>
<Animated.ScrollView
showsVerticalScrollIndicator={false}
scrollEventThrottle={16}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true }
)}
>
{/* ── Hero image with parallax ── */}
<View style={styles.imageContainer}>
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: imageTranslateY }] }]}>
<Image source={{ uri: img as string }} style={styles.heroImg} />
</Animated.View>
<LinearGradient
colors={['transparent', isDark ? '#111111' : '#F5F5F5']}
style={styles.gradient}
/>
</View>
{/* ── Content card ── */}
<View style={[styles.contentCard, { backgroundColor: cardBg, borderColor: border }]}>
<View style={styles.badgeRow}>
<View style={[styles.catBadge, { backgroundColor: `${LIME}25` }]}>
<Text style={styles.catBadgeText}>{category}</Text>
</View>
</View>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
<View style={[styles.authorRow, { borderBottomColor: border }]}>
<Image
source={{ uri: `https://i.pravatar.cc/100?u=${author}` }}
style={styles.authorImg}
/>
<View>
<Text style={[styles.authorName, { color: colors.text }]}>{author}</Text>
<Text style={[styles.date, { color: subText }]}>Published 2 hours ago</Text>
</View>
</View>
<Text style={[styles.paragraph, { color: subText }]}>
This is a deep dive into the topic of{' '}
<Text style={{ color: colors.text, fontFamily: 'Outfit_600SemiBold' }}>{title}</Text>.
Implementing modern technologies requires a balance between performance and aesthetics.
In biiproject, we prioritize the user experience by using the latest React Native features.
</Text>
<Text style={[styles.paragraph, { color: subText }]}>
Our modernization engine ensures that every pixel is optimized, every transition is smooth,
and every interaction feels alive with haptic feedback and fluid motion.
</Text>
<View style={[styles.highlightBox, { backgroundColor: isDark ? '#222222' : '#F5F5F5' }]}>
<Feather name="info" size={16} color={LIME} style={{ marginRight: 10 }} />
<Text style={[styles.highlightText, { color: subText }]}>
This content is curated by our AI engine and updated daily.
</Text>
</View>
</View>
<View style={{ height: 120 }} />
</Animated.ScrollView>
{/* ── Floating back button ── */}
<TouchableOpacity
style={styles.backBtn}
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); router.back(); }}
>
<BlurView intensity={40} tint="dark" style={styles.blurBtn}>
<Feather name="chevron-left" size={22} color="#FFFFFF" />
</BlurView>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
imageContainer: { width, height: IMG_H, overflow: 'hidden' },
heroImg: { width: '100%', height: IMG_H + 60, resizeMode: 'cover' },
gradient: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 160 },
contentCard: { marginHorizontal: 16, marginTop: -32, borderRadius: 28, borderWidth: 1, padding: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.08, shadowRadius: 20, elevation: 6 },
badgeRow: { marginBottom: 12 },
catBadge: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
catBadgeText: { fontSize: 10, fontFamily: 'Outfit_800ExtraBold', textTransform: 'uppercase', letterSpacing: 0.5, color: '#5A7000' },
title: { fontSize: 26, fontFamily: 'Outfit_800ExtraBold', lineHeight: 34, marginBottom: 20 },
authorRow: { flexDirection: 'row', alignItems: 'center', paddingBottom: 20, marginBottom: 20, borderBottomWidth: 1 },
authorImg: { width: 42, height: 42, borderRadius: 21, marginRight: 12 },
authorName: { fontSize: 15, fontFamily: 'Outfit_700Bold' },
date: { fontSize: 12, fontFamily: 'Outfit_400Regular', marginTop: 2 },
paragraph: { fontSize: 15, fontFamily: 'Outfit_400Regular', lineHeight: 26, marginBottom: 18 },
highlightBox: { flexDirection: 'row', alignItems: 'flex-start', borderRadius: 14, padding: 14, marginTop: 6 },
highlightText: { flex: 1, fontSize: 13, fontFamily: 'Outfit_400Regular', lineHeight: 20 },
backBtn: { position: 'absolute', top: Platform.OS === 'ios' ? 52 : 32, left: 20, zIndex: 100 },
blurBtn: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
});
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import { Redirect } from 'expo-router';
import { useAuth } from '../context/AuthContext';
export default function Index() {
const { user, isLoading } = useAuth();
// Wait for auth to settle
if (isLoading) return null;
// Smart redirect: If we have a user with an ID, go to dashboard, otherwise login
const isAuthenticated = !!(user && user.id);
return <Redirect href={isAuthenticated ? '/(tabs)' : '/(auth)/login'} />;
}
+29
View File
@@ -0,0 +1,29 @@
import { Link } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+7
View File
@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};
+155
View File
@@ -0,0 +1,155 @@
import React, { useEffect, useRef } from 'react';
import { StyleSheet, Dimensions, Text, View, StatusBar, Animated, Easing } from 'react-native';
import { useAppTheme } from '../context/ThemeContext';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppConfig } from '../context/ConfigContext';
import { Image } from 'expo-image';
const { width, height } = Dimensions.get('window');
interface AnimatedSplashProps {
onAnimationComplete: () => void;
}
export const AnimatedSplash: React.FC<AnimatedSplashProps> = ({ onAnimationComplete }) => {
const { isDark, colors } = useAppTheme();
const { config } = useAppConfig();
// Using standard React Native Animated for safety
const logoScale = useRef(new Animated.Value(0.5)).current;
const logoOpacity = useRef(new Animated.Value(0)).current;
const containerOpacity = useRef(new Animated.Value(1)).current;
const textOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
// 1. Entry Animation
Animated.parallel([
Animated.timing(logoOpacity, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(logoScale, {
toValue: 1,
duration: 1200,
easing: Easing.out(Easing.back(1.5)),
useNativeDriver: true
}),
Animated.timing(textOpacity, {
toValue: 1,
duration: 800,
delay: 1000,
useNativeDriver: true
})
]).start();
// 2. Clear Exit
const timer = setTimeout(() => {
Animated.timing(containerOpacity, {
toValue: 0,
duration: 600,
useNativeDriver: true
}).start(() => {
// Delay to ensure the animation frame finishes before state change triggers unmount
setTimeout(() => {
onAnimationComplete();
}, 100);
});
}, 3500);
return () => clearTimeout(timer);
}, []);
return (
<Animated.View style={[styles.container, { opacity: containerOpacity, backgroundColor: isDark ? '#020617' : '#F8FAFC' }]}>
<StatusBar hidden />
<View style={styles.centerBox}>
<Animated.View style={[
styles.logoWrapper,
{
opacity: logoOpacity,
transform: [{ scale: logoScale }],
}
]}>
{config?.branding?.logo_url ? (
<Image
source={{ uri: config.branding.logo_url }}
style={{ width: 100, height: 100 }}
contentFit="contain"
/>
) : (
<MaterialCommunityIcons name="shield-check" size={80} color={isDark ? '#38BDF8' : colors.primary || '#6C63FF'} />
)}
</Animated.View>
<Animated.View style={[
styles.textWrapper,
{
opacity: textOpacity,
transform: [{
translateY: textOpacity.interpolate({
inputRange: [0, 1],
outputRange: [30, 0]
})
}]
}
]}>
<View style={styles.brandContainer}>
<Text style={[styles.brandAI, { color: isDark ? '#FFF' : '#0F172A' }]}>
{config?.branding?.app_name || 'biiproject'}
</Text>
</View>
<View style={styles.taglineBox}>
<View style={styles.line} />
<Text style={styles.tagline}>{config?.branding?.app_tagline || 'DIGITAL SOLUTIONS'}</Text>
<View style={styles.line} />
</View>
</Animated.View>
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
},
centerBox: {
alignItems: 'center',
},
logoWrapper: {
width: 150,
height: 150,
justifyContent: 'center',
alignItems: 'center',
},
brandContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: 35,
},
brandAI: {
fontSize: 46,
fontFamily: 'Outfit_700Bold',
letterSpacing: 2,
},
textWrapper: {
alignItems: 'center',
},
taglineBox: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 15,
},
line: {
width: 25,
height: 1,
backgroundColor: '#E2E8F0',
marginHorizontal: 12,
},
tagline: {
fontSize: 11,
color: '#94A3B8',
fontFamily: 'Outfit_700Bold',
letterSpacing: 3,
}
});
+100
View File
@@ -0,0 +1,100 @@
import React, { useEffect, useRef } from 'react';
import { View, Text, StyleSheet, Animated, TouchableOpacity, Platform } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../context/ThemeContext';
interface AnnouncementProps {
visible: boolean;
message: string;
type?: 'info' | 'warning' | 'danger';
onClose?: () => void;
}
export const AnnouncementBanner = ({ visible, message, type = 'info', onClose }: AnnouncementProps) => {
const { colors, isDark } = useAppTheme();
const slideAnim = useRef(new Animated.Value(-100)).current;
const hasShownRef = useRef(false);
useEffect(() => {
if (visible) {
hasShownRef.current = true;
Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: true,
tension: 40,
friction: 7
}).start();
} else {
Animated.timing(slideAnim, {
toValue: -150,
duration: 300,
useNativeDriver: true,
}).start();
}
}, [visible]);
if (!visible && !hasShownRef.current) return null;
const getTheme = () => {
switch (type) {
case 'warning': return { bg: '#FFB000', icon: 'alert-circle', text: '#000' };
case 'danger': return { bg: colors.error, icon: 'slash', text: '#FFF' };
default: return { bg: colors.primary, icon: 'info', text: isDark ? '#000' : '#000' };
}
};
const theme = getTheme();
return (
<Animated.View
style={[
styles.container,
{
backgroundColor: theme.bg,
transform: [{ translateY: slideAnim }]
}
]}
>
<View style={styles.content}>
<Feather name={theme.icon as any} size={18} color={theme.text} />
<Text style={[styles.message, { color: theme.text }]}>{message}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<Feather name="x" size={18} color={theme.text} />
</TouchableOpacity>
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 2000,
paddingTop: Platform.OS === 'ios' ? 50 : 30,
paddingBottom: 15,
paddingHorizontal: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 10,
elevation: 10,
},
content: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
message: {
flex: 1,
fontSize: 13,
fontFamily: 'Outfit_600SemiBold',
lineHeight: 18,
},
closeBtn: {
padding: 4,
opacity: 0.7,
}
});
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import {
RefreshControl,
Platform,
StyleSheet,
View,
StatusBar
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import { useAppTheme } from '../context/ThemeContext';
import { useRefresh } from '../context/RefreshContext';
interface AppScreenProps {
children: React.ReactNode;
scrollable?: boolean;
onRefresh?: () => Promise<void>;
containerStyle?: any;
}
export const AppScreen = ({ children, scrollable = true, onRefresh, containerStyle }: AppScreenProps) => {
const { colors, isDark } = useAppTheme();
const { refreshing, refreshAll } = useRefresh();
const insets = useSafeAreaInsets();
const handleRefresh = async () => {
await refreshAll();
if (onRefresh) {
await onRefresh();
}
};
const bg = isDark ? '#111111' : '#F5F5F5';
const content = (
<KeyboardAwareScrollView
style={{ flex: 1 }}
contentContainerStyle={{ flexGrow: 1 }}
showsVerticalScrollIndicator={false}
enableOnAndroid={true}
extraScrollHeight={Platform.OS === 'ios' ? 40 : 60}
keyboardShouldPersistTaps="handled"
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.primary}
colors={[colors.primary]}
progressBackgroundColor={isDark ? '#1A1A1A' : '#FFFFFF'}
/>
}
>
<View style={{ flex: 1, paddingBottom: insets.bottom }}>
{children}
</View>
</KeyboardAwareScrollView>
);
return (
<View style={[styles.container, { backgroundColor: bg }, containerStyle]}>
<StatusBar barStyle={isDark ? "light-content" : "dark-content"} translucent backgroundColor="transparent" />
{/* Handling top padding manually for more control than default SafeAreaView */}
<View style={[
{ flex: 1, paddingTop: insets.top },
Platform.OS === 'web' && styles.webMaxWidth
]}>
{scrollable ? content : <View style={{ flex: 1, paddingBottom: insets.bottom }}>{children}</View>}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
webMaxWidth: {
width: '100%',
backgroundColor: 'transparent',
}
});
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import { TouchableOpacity, Text, StyleSheet, ViewStyle, TextStyle, ActivityIndicator, StyleProp } from 'react-native';
import * as Haptics from 'expo-haptics';
import { useAppTheme } from '../context/ThemeContext';
interface ButtonProps {
onPress: () => void;
title: string;
variant?: 'primary' | 'secondary' | 'outline' | 'error';
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
disabled?: boolean;
loading?: boolean;
}
const LIME = '#C6F135';
export const Button: React.FC<ButtonProps> = ({
onPress,
title,
variant = 'primary',
style,
textStyle,
disabled,
loading
}) => {
const { colors, isDark } = useAppTheme();
const handlePress = () => {
if (disabled || loading) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
};
const getBackgroundColor = () => {
if (disabled || loading) return isDark ? '#222' : '#EEE';
switch (variant) {
case 'primary': return isDark ? LIME : '#1A1A1A';
case 'secondary': return isDark ? '#2A2A2A' : '#F5F5F5';
case 'outline': return 'transparent';
case 'error': return '#EF4444';
default: return isDark ? LIME : '#1A1A1A';
}
};
const getTextColor = () => {
if (disabled || loading) return isDark ? '#444' : '#BBB';
switch (variant) {
case 'outline': return isDark ? LIME : '#1A1A1A';
case 'primary': return isDark ? '#1A1A1A' : '#FFFFFF';
case 'secondary': return colors.text;
case 'error': return '#FFFFFF';
default: return isDark ? '#1A1A1A' : '#FFFFFF';
}
};
return (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.85}
disabled={disabled || loading}
style={[
styles.button,
{ backgroundColor: getBackgroundColor() },
variant === 'outline' && { borderWidth: 1.5, borderColor: isDark ? LIME : '#1A1A1A' },
style
]}
>
{loading ? (
<ActivityIndicator color={getTextColor()} />
) : (
<Text style={[styles.text, { color: getTextColor() }, textStyle]}>
{title}
</Text>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
height: 58,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
},
text: {
fontSize: 16,
fontFamily: 'Outfit_700Bold',
},
});
+129
View File
@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useAppTheme } from '../context/ThemeContext';
import { Feather } from '@expo/vector-icons';
import { Popup } from './Popup';
interface DropdownProps {
label?: string;
value: string;
options: string[];
onSelect: (val: string) => void;
required?: boolean;
infoText?: string;
placeholder?: string;
}
const LIME = '#C6F135';
export const Dropdown: React.FC<DropdownProps> = ({
label,
value,
options,
onSelect,
required = false,
infoText,
placeholder = 'Choose an option...'
}) => {
const { colors, isDark } = useAppTheme();
const [isOpen, setIsOpen] = useState(false);
const bg = isDark ? '#1A1A1A' : '#F5F5F5';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<View style={styles.container}>
{label && (
<View style={styles.labelRow}>
<Text style={[styles.label, { color: colors.textSecondary }]}>
{label}
{required ? <Text style={{ color: '#EF4444' }}> *</Text> : <Text style={{ color: isDark ? '#666' : '#CCC', fontSize: 10 }}> (Optional)</Text>}
</Text>
</View>
)}
<TouchableOpacity
style={[styles.inputBox, { backgroundColor: bg, borderColor: border }]}
onPress={() => setIsOpen(true)}
>
<Text style={[styles.inputText, { color: value ? colors.text : (isDark ? '#444' : '#BBB') }]}>
{value || placeholder}
</Text>
<Feather name="chevron-down" size={18} color={isDark ? '#555' : '#AAA'} />
</TouchableOpacity>
{infoText && <Text style={[styles.infoText, { color: isDark ? '#6B6B6B' : '#9B9B9B' }]}>{infoText}</Text>}
<Popup visible={isOpen} onClose={() => setIsOpen(false)} title={`${label || 'Option'}`}>
<View style={styles.listContainer}>
{options.map((opt, idx) => (
<TouchableOpacity
key={idx}
style={[styles.optionItem, { borderBottomColor: border, borderBottomWidth: idx < options.length - 1 ? 1 : 0 }]}
onPress={() => {
onSelect(opt);
setIsOpen(false);
}}
>
<Text style={[styles.optionText, { color: colors.text, fontFamily: value === opt ? 'Outfit_700Bold' : 'Outfit_400Regular' }]}>
{opt}
</Text>
{value === opt && <Feather name="check-circle" size={18} color={LIME} />}
</TouchableOpacity>
))}
</View>
</Popup>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 18,
width: '100%',
},
labelRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginBottom: 8,
},
label: {
fontSize: 11,
fontFamily: 'Outfit_700Bold',
letterSpacing: 1,
textTransform: 'uppercase',
},
inputBox: {
height: 56,
borderRadius: 14,
paddingHorizontal: 16,
borderWidth: 1.5,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
inputText: {
fontSize: 15,
fontFamily: 'Outfit_500Medium',
flex: 1,
},
infoText: {
fontSize: 12,
marginTop: 6,
marginLeft: 4,
fontFamily: 'Outfit_400Regular',
},
listContainer: {
paddingTop: 8,
},
optionItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 18,
},
optionText: {
fontSize: 15,
},
});
+225
View File
@@ -0,0 +1,225 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView } from 'react-native';
import { Popup } from './Popup';
import { Input } from './Input';
import { Dropdown } from './Dropdown';
import { Button } from './Button';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../context/ThemeContext';
import * as ImagePicker from 'expo-image-picker';
import { useToast } from '../context/ToastContext';
interface DynamicFormPopupProps {
visible: boolean;
onClose: () => void;
formType: string | null;
}
const LIME = '#C6F135';
export const DynamicFormPopup: React.FC<DynamicFormPopupProps> = ({ visible, onClose, formType }) => {
const { colors, isDark } = useAppTheme();
const { showToast } = useToast();
const [images, setImages] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [field1, setField1] = useState('');
const [field2, setField2] = useState('');
const [field3, setField3] = useState('');
const pickImage = async (useCamera: boolean) => {
try {
let result;
if (useCamera) {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
showToast('Camera permission denied', 'error');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
});
} else {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
showToast('Gallery permission denied', 'error');
return;
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
});
}
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
} catch (error) {
console.error(error);
showToast('Error selecting image', 'error');
}
};
const removeImage = (index: number) => {
const newImages = [...images];
newImages.splice(index, 1);
setImages(newImages);
};
const handleSubmit = () => {
setLoading(true);
setTimeout(() => {
setLoading(false);
showToast(`${formType} submitted successfully!`, 'success');
setField1('');
setField2('');
setField3('');
setImages([]);
onClose();
}, 1500);
};
const renderFields = () => {
switch (formType) {
case 'Feature A':
return (
<>
<Input label="Reference ID" value={field1} onChangeText={setField1} required placeholder="Example: REF-001" />
<Dropdown label="Category" value={field2} options={['Option 1', 'Option 2', 'Option 3']} onSelect={setField2} required />
<Input label="Description" value={field3} onChangeText={setField3} multiline required infoText="Enter detailed information here." placeholder="Lorem ipsum dolor sit amet..." />
</>
);
case 'Feature B':
return (
<>
<Dropdown label="Type" value={field1} options={['Type A', 'Type B', 'Type C']} onSelect={setField1} required />
<Input label="Location" value={field2} onChangeText={setField2} required placeholder="Example: Area 51" />
<Input label="Notes" value={field3} onChangeText={setField3} multiline required infoText="Additional notes or comments." />
</>
);
default:
return (
<>
<Input label="Title" value={field1} onChangeText={setField1} required placeholder="Enter entry title" />
<Input label="Additional Info" value={field2} onChangeText={setField2} multiline placeholder="Enter details..." />
</>
);
}
};
if (!formType) return null;
const bg = isDark ? '#1A1A1A' : '#FFFFFF';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<Popup visible={visible} onClose={onClose} title={`${formType}`} type="bottom">
<View style={styles.container}>
{renderFields()}
<Text style={[styles.label, { color: isDark ? '#6B6B6B' : '#9B9B9B' }]}>Attachments</Text>
<View style={styles.actionRow}>
<TouchableOpacity
style={[styles.attachBtn, { backgroundColor: isDark ? '#222' : '#F5F5F5', borderColor: border }]}
onPress={() => pickImage(true)}
>
<Feather name="camera" size={18} color={LIME} />
<Text style={[styles.attachText, { color: colors.text }]}>Camera</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.attachBtn, { backgroundColor: isDark ? '#222' : '#F5F5F5', borderColor: border }]}
onPress={() => pickImage(false)}
>
<Feather name="image" size={18} color={LIME} />
<Text style={[styles.attachText, { color: colors.text }]}>Gallery</Text>
</TouchableOpacity>
</View>
{images.length > 0 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.imageList}>
{images.map((uri, idx) => (
<View key={idx} style={styles.imageWrapper}>
<Image source={{ uri }} style={[styles.imagePreview, { borderColor: border }]} />
<TouchableOpacity
style={[styles.deleteBadge, { backgroundColor: '#EF4444' }]}
onPress={() => removeImage(idx)}
>
<Feather name="x" size={10} color="#FFF" />
</TouchableOpacity>
</View>
))}
</ScrollView>
)}
<Button
title="Submit Entry"
onPress={handleSubmit}
loading={loading}
style={{ marginTop: 24 }}
/>
</View>
</Popup>
);
};
const styles = StyleSheet.create({
container: {
paddingTop: 8,
},
label: {
fontSize: 11,
fontFamily: 'Outfit_700Bold',
marginBottom: 10,
marginTop: 16,
textTransform: 'uppercase',
letterSpacing: 1,
},
actionRow: {
flexDirection: 'row',
gap: 12,
marginBottom: 16,
},
attachBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
borderRadius: 14,
borderWidth: 1,
gap: 10,
},
attachText: {
fontSize: 14,
fontFamily: 'Outfit_600SemiBold',
},
imageList: {
flexDirection: 'row',
marginBottom: 10,
},
imageWrapper: {
marginRight: 14,
position: 'relative',
},
imagePreview: {
width: 80,
height: 80,
borderRadius: 14,
borderWidth: 1,
},
deleteBadge: {
position: 'absolute',
top: -6,
right: -6,
width: 22,
height: 22,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: '#FFF',
},
});
+78
View File
@@ -0,0 +1,78 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView } from 'react-native';
import { Feather } from '@expo/vector-icons';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
// Technical fix: Report error to backend logs
const { ApiService } = require('../services/api');
ApiService.reportError(error.message, 'critical', {
componentStack: errorInfo.componentStack,
platform: require('react-native').Platform.OS,
});
}
private handleReset = () => {
this.setState({ hasError: false, error: null });
};
public render() {
if (this.state.hasError) {
return (
<View key="error-fallback" style={[styles.container, { paddingTop: 60 }]}>
<View style={styles.content}>
<View style={styles.iconBox}>
<Feather name="alert-triangle" size={60} color="#FF4B4B" />
</View>
<Text style={styles.title}>Oops! Something went wrong</Text>
<Text style={styles.desc}>
An unexpected error occurred. Don't worry, your data is safe.
</Text>
{__DEV__ && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{this.state.error?.toString()}</Text>
</View>
)}
<TouchableOpacity style={styles.btn} onPress={this.handleReset}>
<Text style={styles.btnText}>Try Again</Text>
</TouchableOpacity>
</View>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#FFFFFF' },
content: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40 },
iconBox: { width: 120, height: 120, borderRadius: 40, backgroundColor: '#FFF0F0', alignItems: 'center', justifyContent: 'center', marginBottom: 30 },
title: { fontSize: 24, fontFamily: 'Outfit_800ExtraBold', color: '#1A1A1A', textAlign: 'center', marginBottom: 12 },
desc: { fontSize: 15, fontFamily: 'Outfit_400Regular', color: '#666', textAlign: 'center', lineHeight: 22, marginBottom: 40 },
errorBox: { width: '100%', padding: 16, backgroundColor: '#F5F5F5', borderRadius: 12, marginBottom: 30 },
errorText: { fontSize: 12, fontFamily: 'Monaco', color: '#888' },
btn: { width: '100%', height: 56, borderRadius: 16, backgroundColor: '#1A1A1A', alignItems: 'center', justifyContent: 'center' },
btnText: { color: '#FFFFFF', fontSize: 16, fontFamily: 'Outfit_700Bold' },
});
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
import { BlurView } from 'expo-blur';
import { View, StyleSheet, ViewStyle, Platform } from 'react-native';
import { Theme } from '../constants/theme';
import { useAppTheme } from '../context/ThemeContext';
interface GlassViewProps {
children: React.ReactNode;
style?: ViewStyle;
intensity?: number;
}
export const GlassView: React.FC<GlassViewProps> = ({ children, style, intensity = 20 }) => {
const { isDark, colors } = useAppTheme();
return (
<View style={[
styles.container,
{ backgroundColor: colors.glass, borderColor: colors.border },
style
]}>
{Platform.OS !== 'android' ? (
<BlurView
intensity={intensity}
tint={isDark ? 'dark' : 'light'}
style={StyleSheet.absoluteFill}
/>
) : null}
{children}
</View>
);
};
const styles = StyleSheet.create({
container: {
borderRadius: Theme.radius.lg,
overflow: 'hidden',
borderWidth: 1,
},
});
+144
View File
@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { View, TextInput, Text, StyleSheet, ViewStyle, TouchableOpacity, Platform } from 'react-native';
import { useAppTheme } from '../context/ThemeContext';
import { Feather } from '@expo/vector-icons';
interface InputProps {
label?: string;
placeholder?: string;
value: string;
onChangeText: (text: string) => void;
secureTextEntry?: boolean;
style?: ViewStyle;
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
multiline?: boolean;
required?: boolean;
infoText?: string;
}
const LIME = '#C6F135';
export const Input: React.FC<InputProps> = ({
label,
placeholder,
value,
onChangeText,
secureTextEntry,
style,
keyboardType = 'default',
multiline = false,
required = false,
infoText
}) => {
const { colors, isDark } = useAppTheme();
const [isFocused, setIsFocused] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const isSecure = secureTextEntry && !isPasswordVisible;
const bg = isDark ? '#1A1A1A' : '#F5F5F5';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<View style={[styles.container, style]}>
{label && (
<View style={styles.labelRow}>
<Text style={[styles.label, { color: colors.textSecondary }]}>
{label}
{required && <Text style={{ color: '#EF4444' }}> *</Text>}
</Text>
</View>
)}
<View style={[
styles.inputWrapper,
{
backgroundColor: bg,
borderColor: isFocused ? LIME : border,
},
multiline && { height: 120, borderRadius: 20, alignItems: 'flex-start', paddingTop: 16 }
]}>
<TextInput
style={[
styles.input,
{ color: colors.text, paddingRight: secureTextEntry ? 45 : 16 },
Platform.OS === 'web' && { outlineStyle: 'none' } as any
]}
placeholder={placeholder}
placeholderTextColor={isDark ? '#444' : '#BBB'}
value={value}
onChangeText={onChangeText}
secureTextEntry={isSecure}
onFocus={handleFocus}
onBlur={handleBlur}
keyboardType={keyboardType}
autoCapitalize="none"
multiline={multiline}
textAlignVertical={multiline ? 'top' : 'center'}
/>
{secureTextEntry && (
<TouchableOpacity
style={styles.eyeIcon}
onPress={() => setIsPasswordVisible(!isPasswordVisible)}
>
<Feather
name={isPasswordVisible ? "eye" : "eye-off"}
size={18}
color={isDark ? '#555' : '#AAA'}
/>
</TouchableOpacity>
)}
</View>
{infoText && <Text style={[styles.infoText, { color: isDark ? '#6B6B6B' : '#9B9B9B' }]}>{infoText}</Text>}
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 18,
width: '100%',
},
labelRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginBottom: 8,
},
label: {
fontSize: 11,
fontFamily: 'Outfit_700Bold',
letterSpacing: 1,
textTransform: 'uppercase',
},
inputWrapper: {
height: 56,
borderRadius: 14,
paddingLeft: 16,
borderWidth: 1.5,
flexDirection: 'row',
alignItems: 'center',
},
input: {
flex: 1,
fontSize: 15,
fontFamily: 'Outfit_500Medium',
height: '100%',
},
eyeIcon: {
position: 'absolute',
right: 15,
height: '100%',
justifyContent: 'center',
paddingHorizontal: 5,
},
infoText: {
fontSize: 12,
marginTop: 6,
marginLeft: 4,
fontFamily: 'Outfit_400Regular',
},
});
+146
View File
@@ -0,0 +1,146 @@
import React from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity, Linking } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../context/ThemeContext';
interface KillSwitchProps {
visible: boolean;
message?: string;
supportEmail?: string;
}
export const KillSwitchOverlay = ({ visible, message, supportEmail }: KillSwitchProps) => {
const { colors, isDark } = useAppTheme();
return (
<Modal visible={visible} transparent animationType="fade">
<View style={[styles.container, { backgroundColor: isDark ? '#0A0A0A' : '#F8FAFC' }]}>
<View style={styles.content}>
<View style={[styles.glow, { backgroundColor: colors.error, opacity: 0.1 }]} />
<View style={[styles.iconContainer, { backgroundColor: isDark ? '#1A1A1A' : '#FFF' }]}>
<View style={[styles.iconBox, { backgroundColor: `${colors.error}15` }]}>
<Feather name="shield-off" size={48} color={colors.error} />
</View>
</View>
<Text style={[styles.title, { color: colors.text }]}>System Maintenance</Text>
<View style={styles.messageBox}>
<Text style={[styles.message, { color: isDark ? '#94A3B8' : '#64748B' }]}>
{message || "We're currently performing urgent system maintenance to improve your experience. Please check back later."}
</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: isDark ? '#1E293B' : '#F1F5F9' }]}>
<View style={[styles.dot, { backgroundColor: colors.error }]} />
<Text style={[styles.statusText, { color: colors.textSecondary }]}>
Service Temporarily Unavailable
</Text>
</View>
{supportEmail && (
<TouchableOpacity
activeOpacity={0.7}
style={[styles.supportBtn, { backgroundColor: isDark ? '#FFF' : '#111' }]}
onPress={() => Linking.openURL(`mailto:${supportEmail}`)}
>
<Feather name="mail" size={16} color={isDark ? '#000' : '#FFF'} style={{ marginRight: 8 }} />
<Text style={[styles.supportText, { color: isDark ? '#000' : '#FFF' }]}>Contact Support</Text>
</TouchableOpacity>
)}
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
glow: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 150,
top: '10%',
},
content: {
alignItems: 'center',
width: '100%',
},
iconContainer: {
padding: 20,
borderRadius: 40,
marginBottom: 32,
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.1,
shadowRadius: 20,
},
iconBox: {
width: 100,
height: 100,
borderRadius: 30,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 32,
fontFamily: 'Outfit_800ExtraBold',
textAlign: 'center',
marginBottom: 20,
letterSpacing: -0.5,
},
messageBox: {
paddingHorizontal: 10,
marginBottom: 40,
},
message: {
fontSize: 16,
fontFamily: 'Outfit_400Regular',
textAlign: 'center',
lineHeight: 26,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 50,
gap: 10,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
statusText: {
fontSize: 13,
fontFamily: 'Outfit_600SemiBold',
letterSpacing: 0.5,
textTransform: 'uppercase',
},
supportBtn: {
marginTop: 60,
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 32,
borderRadius: 20,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
},
supportText: {
fontSize: 15,
fontFamily: 'Outfit_700Bold',
},
});
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import { View, ActivityIndicator, StyleSheet, Modal, Dimensions } from 'react-native';
import { useAppTheme } from '../context/ThemeContext';
interface LoadingOverlayProps {
visible: boolean;
}
const { width } = Dimensions.get('window');
const LIME = '#C6F135';
export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible }) => {
const { isDark } = useAppTheme();
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<Modal transparent visible={visible} animationType="fade">
<View style={styles.container}>
<View style={[
styles.card,
{ backgroundColor: cardBg, borderColor: border }
]}>
<ActivityIndicator size="large" color={LIME} />
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
justifyContent: 'center',
alignItems: 'center',
},
card: {
width: 90,
height: 90,
borderRadius: 24,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 10,
},
});
+69
View File
@@ -0,0 +1,69 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Platform, Animated } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export const NetworkStatus = () => {
const [isConnected, setIsConnected] = useState<boolean | null>(true);
const insets = useSafeAreaInsets();
const opacityAnim = useState(new Animated.Value(1))[0];
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(state => {
const connected = !!state.isConnected && !!state.isInternetReachable;
if (isConnected !== connected) {
setIsConnected(connected);
}
});
return () => unsubscribe();
}, [isConnected]);
// Optionally fade it out if online? Or keep it permanently. Let's keep it permanently but subtle if online.
const isOnline = isConnected !== false;
return (
<View style={[styles.container, { top: Math.max(insets.top, Platform.OS === 'ios' ? 44 : 10) }]} pointerEvents="none">
<Animated.View style={[
styles.pill,
{
backgroundColor: isOnline ? 'rgba(52, 199, 89, 0.15)' : 'rgba(255, 59, 48, 0.15)',
borderColor: isOnline ? 'rgba(52, 199, 89, 0.3)' : 'rgba(255, 59, 48, 0.3)',
}
]}>
<View style={[styles.dot, { backgroundColor: isOnline ? '#34C759' : '#FF3B30' }]} />
<Text style={[styles.text, { color: isOnline ? '#34C759' : '#FF3B30' }]}>
{isOnline ? 'Online' : 'Offline'}
</Text>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
right: 24,
zIndex: 9999,
},
pill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
marginRight: 6,
},
text: {
fontSize: 10,
fontWeight: '700',
fontFamily: 'Outfit_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
});
+120
View File
@@ -0,0 +1,120 @@
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity, ScrollView, Pressable, Platform, Animated, Easing } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useAppTheme } from '../context/ThemeContext';
interface PopupProps {
visible: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
showCloseBtn?: boolean;
type?: 'center' | 'bottom';
}
export const Popup: React.FC<PopupProps> = ({
visible,
onClose,
title,
children,
showCloseBtn = true,
type = 'center'
}) => {
const { colors, isDark } = useAppTheme();
const [shouldRender, setShouldRender] = useState(visible);
const opacity = useRef(new Animated.Value(0)).current;
const scale = useRef(new Animated.Value(type === 'center' ? 0.9 : 1)).current;
const translateY = useRef(new Animated.Value(type === 'bottom' ? 600 : 0)).current;
useEffect(() => {
if (visible) {
setShouldRender(true);
Animated.parallel([
Animated.timing(opacity, { toValue: 1, duration: 300, useNativeDriver: true }),
type === 'center'
? Animated.spring(scale, { toValue: 1, damping: 15, stiffness: 100, useNativeDriver: true })
: Animated.spring(translateY, { toValue: 0, damping: 20, stiffness: 90, useNativeDriver: true })
]).start();
} else {
Animated.parallel([
Animated.timing(opacity, { toValue: 0, duration: 250, useNativeDriver: true }),
type === 'center'
? Animated.timing(scale, { toValue: 0.95, duration: 250, useNativeDriver: true })
: Animated.timing(translateY, { toValue: 600, duration: 250, useNativeDriver: true })
]).start(() => {
setShouldRender(false);
});
}
}, [visible, type]);
if (!shouldRender) return null;
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
const border = isDark ? '#2A2A2A' : '#EEEEEE';
return (
<Modal visible={shouldRender} transparent statusBarTranslucent animationType="none" onRequestClose={onClose}>
<View style={[
styles.overlay,
type === 'bottom' && { justifyContent: 'flex-end', padding: 0 }
]}>
<Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.7)', opacity }]} />
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
<Animated.View style={[
styles.content,
{
backgroundColor: cardBg,
borderColor: border,
borderWidth: 1,
opacity,
transform: [
{ scale: type === 'center' ? scale : 1 },
{ translateY: type === 'bottom' ? translateY : 0 }
]
},
type === 'bottom' && {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
borderTopLeftRadius: 36,
borderTopRightRadius: 36,
maxWidth: '100%',
maxHeight: '92%',
}
]}>
{type === 'bottom' && (
<View style={[styles.handle, { backgroundColor: isDark ? '#333' : '#E0E0E0' }]} />
)}
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
{showCloseBtn && (
<TouchableOpacity onPress={onClose} style={[styles.closeBtn, { backgroundColor: isDark ? '#222' : '#F5F5F5' }]}>
<Feather name="x" size={18} color={isDark ? '#FFF' : '#1A1A1A'} />
</TouchableOpacity>
)}
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
</Animated.View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
content: { width: '100%', maxWidth: 420, maxHeight: '85%', borderRadius: 28, overflow: 'hidden', elevation: 20 },
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 12 },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 24, paddingTop: 24, paddingBottom: 16 },
title: { fontSize: 22, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
closeBtn: { width: 34, height: 34, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
scrollContent: { paddingHorizontal: 24, paddingBottom: Platform.OS === 'ios' ? 40 : 24 },
});
+303
View File
@@ -0,0 +1,303 @@
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TextInput,
Dimensions,
ActivityIndicator,
Platform,
Animated,
Easing
} from 'react-native';
import * as Haptics from 'expo-haptics';
import { LinearGradient } from 'expo-linear-gradient';
import { MaterialCommunityIcons, Feather } from '@expo/vector-icons';
import { useAppTheme } from '../context/ThemeContext';
const { width } = Dimensions.get('window');
// ── AICard: Premium Clean Card ───────────────────────
export const AICard = ({ children, style, delay = 0, variant = 'white' }: any) => {
const { isDark, colors } = useAppTheme();
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(30)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 500,
delay,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 500,
delay,
easing: Easing.out(Easing.back(1.5)),
useNativeDriver: true,
})
]).start();
}, [delay]);
let bg: string;
if (variant === 'white') bg = colors.surface;
else if (variant === 'lime') bg = colors.primary;
else if (variant === 'dark') bg = colors.secondary;
else bg = variant;
return (
<Animated.View
style={[
styles.card,
{
backgroundColor: bg,
borderColor: colors.border,
shadowColor: isDark ? '#000' : '#1A1A1A',
opacity: fadeAnim,
transform: [{ translateY: slideAnim }]
},
style
]}
>
{children}
</Animated.View>
);
};
// ── AIButton: High-End CTA Button ────────────────────
export const AIButton = ({ title, onPress, loading, icon, color, style, textStyle }: any) => {
const { isDark, colors } = useAppTheme();
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
if (onPress) onPress();
};
const defaultBg = colors.primary;
const defaultText = colors.secondary;
const btnBg = color || defaultBg;
const isLimeBg = btnBg === colors.primary;
const resolvedTextColor = textStyle?.color || (isLimeBg ? colors.secondary : defaultText);
return (
<TouchableOpacity
onPress={handlePress}
disabled={loading}
activeOpacity={0.82}
style={[
styles.button,
{ backgroundColor: btnBg },
style
]}
>
{loading ? (
<ActivityIndicator color={resolvedTextColor} />
) : (
<View style={styles.buttonContent}>
{icon && (
<MaterialCommunityIcons
name={icon}
size={20}
color={resolvedTextColor}
style={{ marginRight: 8 }}
/>
)}
<Text style={[styles.buttonText, { color: resolvedTextColor }, textStyle]}>{title}</Text>
</View>
)}
</TouchableOpacity>
);
};
// ── AIInput: Themed Input Field ──────────────────────
export const AIInput = ({
label, icon, placeholder, value, onChangeText,
secure, isPassword, style, keyboardType, autoCapitalize = 'none'
}: any) => {
const { colors } = useAppTheme();
const [showPass, setShowPass] = React.useState(false);
const isSecure = secure || isPassword;
return (
<View style={[styles.inputGroup, style]}>
{label && (
<Text style={[styles.inputLabel, { color: colors.textSecondary }]}>{label}</Text>
)}
<View style={[styles.inputField, {
backgroundColor: colors.background,
borderColor: colors.border,
}]}>
{icon && (
<Feather
name={icon}
size={18}
color={colors.textPlaceholder}
style={{ marginRight: 12 }}
/>
)}
<TextInput
placeholder={placeholder}
placeholderTextColor={colors.textPlaceholder}
value={value}
onChangeText={onChangeText}
secureTextEntry={isSecure && !showPass}
style={[styles.textInput, { color: colors.text }]}
autoCapitalize={autoCapitalize}
keyboardType={keyboardType}
/>
{isSecure && (
<TouchableOpacity onPress={() => setShowPass(!showPass)} style={{ padding: 8 }}>
<Feather
name={showPass ? 'eye-off' : 'eye'}
size={18}
color={colors.textPlaceholder}
/>
</TouchableOpacity>
)}
</View>
</View>
);
};
// ── AISectionHeader: Section Title ───────────────────
export const AISectionHeader = ({ title, subtitle, action, onAction, style }: any) => {
const { colors } = useAppTheme();
return (
<View style={[styles.sectionHeader, style]}>
<View style={{ flex: 1 }}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{title}</Text>
{subtitle && (
<Text style={[styles.sectionSub, { color: colors.textSecondary }]}>{subtitle}</Text>
)}
</View>
{action && (
<TouchableOpacity onPress={onAction}>
<Text style={[styles.sectionAction, { color: colors.primary }]}>{action}</Text>
</TouchableOpacity>
)}
</View>
);
};
// ── AILimeBadge: Pill badge with lime accent ──────────
export const AILimeBadge = ({ label, style }: any) => {
const { colors } = useAppTheme();
return (
<View style={[styles.limeBadge, { backgroundColor: colors.primaryMuted }, style]}>
<Text style={[styles.limeBadgeText, { color: colors.primary }]}>{label}</Text>
</View>
);
};
// ── AISkeleton: Premium Shimmer Loader ────────────────
export const AISkeleton = ({ width, height, radius = 12, style }: any) => {
const { isDark, colors } = useAppTheme();
const shimmerAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.loop(
Animated.timing(shimmerAnim, {
toValue: 1,
duration: 1500,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
}, []);
const translateX = shimmerAnim.interpolate({
inputRange: [0, 1],
outputRange: [-300, 300]
});
const bg = colors.surface;
const highlight = colors.surfaceElevated;
return (
<View
style={[
{
width: width || '100%',
height: height || 20,
borderRadius: radius,
backgroundColor: bg,
overflow: 'hidden',
},
style || {},
]}
>
<Animated.View style={[{ width: '100%', height: '100%', transform: [{ translateX }] }]}>
<LinearGradient
colors={[bg, highlight, bg]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={{ flex: 1 }}
/>
</Animated.View>
</View>
);
};
export const AIPressable = ({ children, onPress, style, containerStyle }: any) => {
const scale = useRef(new Animated.Value(1)).current;
const handlePressIn = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Animated.spring(scale, { toValue: 0.97, damping: 10, stiffness: 300, useNativeDriver: true }).start();
};
const handlePressOut = () => {
Animated.spring(scale, { toValue: 1, damping: 10, stiffness: 300, useNativeDriver: true }).start();
};
return (
<TouchableOpacity
activeOpacity={1}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={onPress}
style={style}
>
<Animated.View style={[containerStyle || { flex: 1 }, { transform: [{ scale }] }]}>
{children}
</Animated.View>
</TouchableOpacity>
);
};
// ── AISuccess: Animated Checkmark ──────────────────
export const AISuccess = ({ size = 80 }: { size?: number }) => {
const { colors } = useAppTheme();
const scale = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.spring(scale, { toValue: 1, damping: 12, stiffness: 200, useNativeDriver: true }).start();
}, []);
return (
<Animated.View style={[styles.successCircle, { width: size, height: size, backgroundColor: colors.primary + '20', transform: [{ scale }] }]}>
<Feather name="check" size={size * 0.6} color={colors.primary} />
</Animated.View>
);
};
const styles = StyleSheet.create({
card: { borderRadius: 24, borderWidth: 1, padding: 20, overflow: 'hidden', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.06, shadowRadius: 16, elevation: 3 },
button: { height: 58, borderRadius: 16, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 24 },
buttonContent: { flexDirection: 'row', alignItems: 'center' },
buttonText: { fontSize: 16, fontFamily: 'Outfit_700Bold' },
inputGroup: { marginBottom: 18 },
inputLabel: { fontSize: 12, fontFamily: 'Outfit_600SemiBold', marginBottom: 8, marginLeft: 2, textTransform: 'uppercase', letterSpacing: 0.5 },
inputField: { flexDirection: 'row', alignItems: 'center', height: 56, borderRadius: 14, borderWidth: 1, paddingHorizontal: 16 },
textInput: { flex: 1, fontSize: 15, fontFamily: 'Outfit_500Medium' },
sectionHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, marginBottom: 14 },
sectionTitle: { fontSize: 20, fontFamily: 'Outfit_700Bold' },
sectionSub: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 2 },
sectionAction: { fontSize: 13, fontFamily: 'Outfit_600SemiBold' },
limeBadge: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
limeBadgeText: { fontSize: 11, fontFamily: 'Outfit_700Bold', textTransform: 'uppercase', letterSpacing: 0.5 },
successCircle: { borderRadius: 100, alignItems: 'center', justifyContent: 'center' },
});
+25
View File
@@ -0,0 +1,25 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export function HelloWave() {
return (
<View>
<Text style={styles.text}>👋</Text>
</View>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 28,
lineHeight: 32,
marginTop: -6,
},
});
@@ -0,0 +1,41 @@
import React from 'react';
import { StyleSheet, View, ScrollView } from 'react-native';
interface Props {
children: React.ReactNode;
headerImage: React.ReactElement;
headerBackgroundColor: { dark: string; light: string };
}
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
return (
<View style={styles.container}>
<ScrollView scrollEventThrottle={16}>
<View style={[styles.header, { backgroundColor: headerBackgroundColor.light }]}>
{headerImage}
</View>
<View style={styles.content}>{children}</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: 250,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});
+60
View File
@@ -0,0 +1,60 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});
+14
View File
@@ -0,0 +1,14 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}
+45
View File
@@ -0,0 +1,45 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});
+32
View File
@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}
+37
View File
@@ -0,0 +1,37 @@
export const MOCK_ARTICLES = [
{
id: 'A1',
title: 'How Transformers Work in Deep Learning',
author: 'Dr. Martin Shah',
category: 'LLM',
img: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?auto=format&fit=crop&w=400&q=80',
},
{
id: 'A2',
title: 'AI Ethics in Modern Journalism',
author: 'Alex Wong',
category: 'Ethics',
img: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?auto=format&fit=crop&w=400&q=80',
},
{
id: 'A3',
title: 'Medical Diagnosis with Computer Vision',
author: 'Sarah Lee',
category: 'Health',
img: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=400&q=80',
},
];
export const MOCK_NOTIFICATIONS = [
{ id: '1', title: 'System Sync Complete', desc: 'All configurations have been updated successfully.', time: '2m ago', type: 'success' },
{ id: '2', title: 'Model Updated', desc: 'AI engine has been upgraded to the latest version.', time: '1h ago', type: 'update' },
{ id: '3', title: 'New Login Detected', desc: 'A new login was detected from a MacOS device.', time: '3h ago', type: 'alert' },
{ id: '4', title: 'Usage Report Ready', desc: 'Your weekly usage report is now available.', time: '1d ago', type: 'info' },
{ id: '5', title: 'Subscription Reminder', desc: 'Your plan renews in 3 days. Check payment details.', time: '2d ago', type: 'warning' },
];
export const MOCK_FAQS = [
{ id: '1', q: 'How to initiate a system sync?', a: 'Go to Dashboard and click the Sync System button at the top right.' },
{ id: '2', q: 'How to change security settings?', a: 'Visit Profile > Preferences to manage biometrics and passwords.' },
{ id: '3', q: 'Where to find API documentation?', a: 'Documentation can be accessed via the Help Center links.' },
];
+150
View File
@@ -0,0 +1,150 @@
// ─────────────────────────────────────────────────────
// BIIPROJECT — Design System v3.0
// Palette: Neon Lime (#C6F135) · Charcoal Black (#1A1A1A) · Clean White (#FFFFFF)
// Inspired by: modern fintech/logistics dark-contrast UI
// ─────────────────────────────────────────────────────
export const PALETTE = {
// ── Core ──────────────────────────────────────────
lime: '#C6F135', // Primary Neon Lime — CTA, active states, highlights
limeDark: '#A8D820', // Darker lime for pressed states
limeLight: '#ECFB97', // Soft lime for backgrounds/badges
limeMuted: '#C6F13526', // 15% opacity lime for subtle tints
black: '#111111', // True near-black — primary surfaces/dark mode bg
charcoal: '#1A1A1A', // Charcoal — text, buttons, cards
graphite: '#2A2A2A', // Card surface in dark mode
steel: '#3D3D3D', // Borders, dividers in dark mode
white: '#FFFFFF', // Pure white — light mode backgrounds
offWhite: '#F5F5F5', // Light gray background
snow: '#FAFAFA', // Card surfaces in light mode
mist: '#EEEEEE', // Border / divider in light mode
fog: '#D4D4D4', // Muted / disabled
// ── Semantic ──────────────────────────────────────
textDark: '#111111', // Primary text in light mode
textMuted: '#6B6B6B', // Secondary text in light mode
textDimmed: '#9B9B9B', // Placeholder / timestamp
textLight: '#FFFFFF', // Primary text in dark mode
textLightMuted: '#A3A3A3', // Secondary text in dark mode
// ── Status ────────────────────────────────────────
success: '#22C55E', // Green (status success)
error: '#EF4444', // Red (errors, danger)
warning: '#F59E0B', // Amber (warnings)
info: '#3B82F6', // Blue (info)
};
export const Theme = {
light: {
background: PALETTE.offWhite, // Page background: #F5F5F5
surface: PALETTE.white, // Card surface: #FFFFFF
surfaceLight: PALETTE.snow, // Subtle surface: #FAFAFA
surfaceElevated: PALETTE.white,
primary: PALETTE.lime, // CTA / Active: #C6F135
primaryDark: PALETTE.limeDark, // Press state: #A8D820
primaryLight: PALETTE.limeLight, // Badge bg: #ECFB97
primaryMuted: PALETTE.limeMuted, // Tinted bg: #C6F13526
secondary: PALETTE.charcoal, // Buttons/icons: #1A1A1A
accent: PALETTE.lime,
text: PALETTE.textDark, // #111111
textSecondary: PALETTE.textMuted, // #6B6B6B
textPlaceholder: PALETTE.textDimmed, // #9B9B9B
border: PALETTE.mist, // #EEEEEE
divider: PALETTE.fog, // #D4D4D4
tabBar: PALETTE.white, // Tab bar background
tabBarActive: PALETTE.charcoal, // Active tab icon: charcoal
tabBarInactive: '#A3A3A3', // Inactive tab icon
error: PALETTE.error,
success: PALETTE.success,
warning: PALETTE.warning,
// Legacy compat
darkNav: PALETTE.charcoal,
pastelPurple: PALETTE.limeLight,
pastelBlue: '#C6F135',
pastelYellow: PALETTE.limeLight,
pastelPink: PALETTE.limeMuted,
glass: 'rgba(255, 255, 255, 0.92)',
},
dark: {
background: PALETTE.black, // #111111
surface: PALETTE.graphite, // #2A2A2A
surfaceLight: '#333333',
surfaceElevated: '#3A3A3A',
primary: PALETTE.lime, // Lime stays vibrant in dark: #C6F135
primaryDark: PALETTE.limeDark,
primaryLight: '#3A3D00', // Dark lime tint for dark mode badges
primaryMuted: PALETTE.limeMuted,
secondary: PALETTE.white,
accent: PALETTE.lime,
text: PALETTE.textLight, // #FFFFFF
textSecondary: PALETTE.textLightMuted, // #A3A3A3
textPlaceholder: '#6B6B6B',
border: PALETTE.steel, // #3D3D3D
divider: '#333333',
tabBar: PALETTE.black,
tabBarActive: PALETTE.lime, // Active = lime in dark mode
tabBarInactive: '#6B6B6B',
error: '#FF6B6B',
success: '#4ADE80',
warning: '#FBBF24',
// Legacy compat
darkNav: PALETTE.black,
pastelPurple: '#C6F13515',
pastelBlue: '#C6F13515',
pastelYellow: '#C6F13515',
pastelPink: '#C6F13515',
glass: 'rgba(17, 17, 17, 0.92)',
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 40,
},
radius: {
sm: 12,
md: 20,
lg: 28,
xl: 36,
full: 999,
},
};
export const Colors = {
light: {
text: PALETTE.textDark,
background: PALETTE.offWhite,
tint: PALETTE.lime,
icon: PALETTE.textMuted,
tabIconDefault: '#A3A3A3',
tabIconSelected: PALETTE.charcoal,
},
dark: {
text: PALETTE.textLight,
background: PALETTE.black,
tint: PALETTE.lime,
icon: PALETTE.textLightMuted,
tabIconDefault: '#6B6B6B',
tabIconSelected: PALETTE.lime,
},
};
+165
View File
@@ -0,0 +1,165 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import * as Haptics from 'expo-haptics';
import * as LocalAuthentication from 'expo-local-authentication';
import { storage } from '../utils/storage';
import { Platform } from 'react-native';
import { router } from 'expo-router';
import { ApiService } from '../services/api';
import { DebugLogger } from '../utils/logger';
interface User {
id: string;
name: string;
email: string;
avatar?: string;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
signIn: (email: string, pass: string) => Promise<void>;
signOut: () => void;
signUp: (name: string, email: string, pass: string) => Promise<void>;
updateProfile: (name: string, email: string) => Promise<void>;
syncUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Storage helper is now imported from utils/storage
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadStoredToken();
}, []);
const loadStoredToken = async () => {
try {
const token = await storage.get('user_token');
const storedUser = await storage.get('user_data');
if (token && storedUser) {
setUser(JSON.parse(storedUser));
}
} catch (e) {
console.warn('Authentication failed during boot:', e);
await storage.remove('user_token');
await storage.remove('user_data');
setUser(null);
} finally {
setIsLoading(false);
}
};
const signIn = async (email: string, pass: string) => {
setIsLoading(true);
try {
const response = await ApiService.login(email, pass);
const userData = response.data.user;
const token = response.data.token;
setUser(userData);
await storage.save('user_token', token);
await storage.save('user_data', JSON.stringify(userData));
await storage.save('saved_email', email);
await storage.save('saved_pass', pass);
DebugLogger.log(`User signed in: ${email}`, 'info');
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (error: any) {
DebugLogger.log(`Login failed for ${email}: ${error.message}`, 'error');
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
throw error;
} finally {
setIsLoading(false);
}
};
const signUp = async (name: string, email: string, pass: string) => {
setIsLoading(true);
try {
const response = await ApiService.register(name, email, pass);
const userData = response.data.user;
const token = response.data.token;
setUser(userData);
await storage.save('user_token', token);
await storage.save('user_data', JSON.stringify(userData));
await storage.save('saved_email', email);
await storage.save('saved_pass', pass);
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (error) {
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
throw error;
} finally {
setIsLoading(false);
}
};
const updateProfile = async (name: string, email: string) => {
setIsLoading(true);
try {
const response = await ApiService.updateProfile(name, email);
const userData = response.data.user;
setUser(userData);
await storage.save('user_data', JSON.stringify(userData));
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (error) {
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
throw error;
} finally {
setIsLoading(false);
}
};
const signOut = async () => {
setUser(null);
await storage.remove('user_token');
await storage.remove('user_data');
if (Platform.OS !== 'web') Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
DebugLogger.log('User signed out', 'info');
router.replace('/(auth)/login');
};
const syncUser = async () => {
if (!user) return;
try {
const response = await ApiService.getUser();
const userData = response.data.user;
setUser(userData);
await storage.save('user_data', JSON.stringify(userData));
} catch (error: any) {
DebugLogger.log(`Sync failed: ${error.message}`, 'error');
// If it's a 401, we might want to sign out, but for now let's be silent
// to avoid the "Global refresh failed" loop that annoys the user.
if (error.message.includes('Unauthenticated')) {
console.warn('Silent sync failure: User is unauthenticated but staying in app.');
} else {
throw error;
}
}
};
return (
<AuthContext.Provider value={{ user, isLoading, signIn, signOut, signUp, updateProfile, syncUser }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
return {
user: null,
isLoading: false,
signIn: async () => {},
signOut: () => {},
signUp: async () => {},
updateProfile: async () => {},
syncUser: async () => {}
} as any;
}
return context;
}
+268
View File
@@ -0,0 +1,268 @@
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
import { Platform, AppState, AppStateStatus } from 'react-native';
import { storage } from '../utils/storage';
import { ApiService } from '../services/api';
import NetInfo from '@react-native-community/netinfo';
import { DebugLogger } from '../utils/logger';
interface AppConfig {
branding: {
app_name?: string;
app_tagline?: string;
app_icon_url?: string;
logo_url?: string;
splash_image_url?: string;
brand_color?: string;
theme_color_primary?: string;
theme_color_secondary?: string;
primary_font_family?: string;
};
control_center: {
kill_switch_active?: boolean;
kill_switch_message?: string;
maintenance_start_at?: string;
maintenance_end_at?: string;
maintenance_bypass_ips?: string;
announcement_enabled?: boolean;
announcement_text?: string;
announcement_type?: 'info' | 'warning' | 'danger';
};
app_updates: {
app_version?: string;
min_app_version?: string;
onboarding_version?: string;
store_url_android?: string;
store_url_ios?: string;
store_url_huawei?: string;
};
features: {
enable_registration?: boolean;
enable_guest_mode?: boolean;
require_otp_registration?: boolean;
enable_biometrics?: boolean;
enable_remember_me?: boolean;
review_prompt_enabled?: boolean;
min_actions_before_review?: number;
region_lock_enabled?: boolean;
dashboard_categories?: string;
};
security_auth: {
login_title?: string;
login_subtitle?: string;
token_ttl_minutes?: number;
session_max_age?: number;
login_max_attempts?: number;
biometric_auth_type?: string;
oauth_google_enabled?: boolean;
oauth_apple_enabled?: boolean;
oauth_facebook_enabled?: boolean;
};
connectivity: {
api_base_url?: string;
api_version?: string;
api_timeout_ms?: number;
api_retry_count?: number;
request_cache_ttl?: number;
sync_interval_ms?: number;
enable_ssl_pinning?: boolean;
ssl_pinning_hash?: string;
environment_selector?: string;
};
notifications: {
enable_push_notifications?: boolean;
fcm_topic_default?: string;
default_channel_id?: string;
notification_sound_enabled?: boolean;
badge_count_enabled?: boolean;
priority_level?: string;
};
support_social: {
support_email?: string;
support_whatsapp?: string;
live_chat_url?: string;
faq_url?: string;
privacy_policy_url?: string;
social_instagram_url?: string;
social_twitter_url?: string;
social_facebook_url?: string;
social_youtube_url?: string;
faq_json?: any[];
help_topics_json?: any[];
};
analytics_system: {
crashlytics_enabled?: boolean;
log_level?: string;
event_sampling_rate?: string;
google_analytics_id?: string;
gdpr_compliance_enabled?: boolean;
target_sdk_version?: string;
system_timezone?: string;
default_locale?: string;
};
localization?: {
[lang: string]: Record<string, string>;
};
}
interface ConfigContextType {
config: AppConfig | null;
isLoading: boolean;
isSyncing: boolean;
isConnected: boolean;
syncConfig: () => Promise<void>;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
const STORAGE_KEY = 'cached_mobile_config';
const ETAG_KEY = 'cached_config_etag';
// Minimum sync interval: 3 seconds to feel "instant" while avoiding excessive load
const MIN_SYNC_INTERVAL_MS = 3_000;
const DEFAULT_SYNC_INTERVAL_MS = 5_000;
export function ConfigProvider({ children }: { children: React.ReactNode }) {
const [config, setConfig] = useState<AppConfig | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isConnected, setIsConnected] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const isSyncingRef = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevConnected = useRef<boolean | null>(null);
useEffect(() => {
// 1. Monitor network connection
const unsubscribeNet = NetInfo.addEventListener(state => {
const connected = !!state.isConnected;
if (prevConnected.current !== null && prevConnected.current !== connected) {
DebugLogger.log(`Network status changed: ${connected ? 'Online' : 'Offline'}`, connected ? 'success' : 'error');
if (connected) syncConfig();
}
setIsConnected(connected);
prevConnected.current = connected;
});
// 2. Monitor AppState (Sync on foreground)
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
syncConfig();
}
});
loadInitialConfig();
return () => {
unsubscribeNet();
subscription.remove();
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
// Re-setup interval when config changes (for dynamic sync_interval_ms)
useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
const rawInterval = config?.connectivity?.sync_interval_ms ?? DEFAULT_SYNC_INTERVAL_MS;
// Clamp to minimum to avoid hammering server
const safeInterval = Math.max(rawInterval, MIN_SYNC_INTERVAL_MS);
intervalRef.current = setInterval(() => {
syncConfig();
}, safeInterval);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [config?.connectivity?.sync_interval_ms]);
const loadInitialConfig = async () => {
try {
// 1. Load from cache first (offline-first approach)
const str = await storage.get(STORAGE_KEY);
if (str) {
try {
const cached = JSON.parse(str);
setConfig(cached);
setIsLoading(false);
} catch {
// Cache is corrupted, remove it
await storage.remove(STORAGE_KEY);
}
}
// 2. Fetch fresh config in background (non-blocking)
await syncConfig();
} catch (error) {
console.warn('[Config] Initialization error:', error);
} finally {
setIsLoading(false);
}
};
const syncConfig = async () => {
if (isSyncingRef.current) return;
isSyncingRef.current = true;
setIsSyncing(true);
try {
const etag = await storage.get(ETAG_KEY);
const response = await ApiService.syncMobileConfig(etag);
if (response?.status === 'not_modified') {
DebugLogger.log('Config is up to date (ETag match)', 'sync');
return;
}
if (response?.status === 'success' && response?.data) {
const freshConfig = response.data;
setConfig(freshConfig);
DebugLogger.log(`Config updated with ETag: ${response.etag?.substring(0, 8)}...`, 'sync');
// Persist to cache
await storage.save(STORAGE_KEY, JSON.stringify(freshConfig));
if (response.etag) {
await storage.save(ETAG_KEY, response.etag);
}
}
} catch (error: any) {
DebugLogger.log(`Sync error: ${error.message}`, 'error');
console.warn('[Config] Sync failed (offline?):', error);
} finally {
isSyncingRef.current = false;
setIsSyncing(false);
setIsLoading(false);
}
};
return (
<ConfigContext.Provider value={{ config, isLoading, isSyncing, isConnected, syncConfig }}>
{children}
</ConfigContext.Provider>
);
}
export function useAppConfig() {
const context = useContext(ConfigContext);
if (context === undefined) {
return {
config: {
branding: { app_name: 'biiproject' },
control_center: {},
app_updates: {},
features: {},
security_auth: {},
connectivity: {},
notifications: {},
support_social: {},
analytics_system: {}
},
isLoading: false,
isSyncing: false,
isConnected: true,
syncConfig: async () => {}
} as any;
}
return context;
}
+328
View File
@@ -0,0 +1,328 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { Platform } from 'react-native';
import { storage } from '../utils/storage';
import { useAppConfig } from './ConfigContext';
export type LanguageType = 'English' | 'Indonesian';
interface Translations {
[key: string]: {
[key: string]: string;
};
}
export const translations: Translations = {
English: {
// Auth
login: 'Login Now',
register: 'Register Now',
createAccount: 'Create New Account',
registerSubtitle: 'Join us to start managing your workspace efficiently.',
email: 'Email Address',
password: 'Password',
fullName: 'Full Name',
forgotPass: 'Forgot Password?',
rememberMe: 'Remember Me',
noAccount: "Don't have an account? ",
haveAccount: 'Already have an account? ',
loginJust: 'Login Now',
termsText: 'By registering, you agree to our ',
termsLink: 'Terms of Service',
privacyLink: 'Privacy Policy',
and: ' and ',
// Auth Extra
signIn: 'Sign In',
emailPlaceholder: 'email@example.com',
passwordPlaceholder: '••••••••',
signInNow: 'Sign In Now',
orContinueWith: 'OR CONTINUE WITH',
signUp: 'Sign Up',
google: 'Google',
apple: 'Apple',
welcomeBack: 'Welcome back!',
invalidEmail: 'Invalid email address',
loginFailed: 'Login failed. Please try again.',
bioConfirm: 'Confirm Identity',
bioFailed: 'Biometric authentication failed',
bioSuccess: 'Biometric Login Successful!',
// Auth Extra 2
fillAll: 'Please fill all required fields',
passMismatch: 'Passwords do not match',
accountCreated: 'Account created successfully!',
confirmPassword: 'Confirm Password',
namePlaceholder: 'John Doe',
registering: 'Creating account...',
// Profile Extra
uploadingAvatar: 'Uploading avatar...',
avatarUpdated: 'Profile picture updated!',
profileUpdated: 'Profile updated successfully!',
logoutSafe: 'Signing out safely...',
confirmLogout: 'Are you sure you want to log out?',
cancel: 'Cancel',
logout: 'Logout',
accCreated: 'Account created successfully!',
regFailed: 'Registration failed',
join: 'Join',
confirmPass: 'Confirm Password',
// Dashboard
halo: 'Hello',
role: 'Your Role: ',
lastStatus: 'Last Status: ',
history: 'Recent Activity',
searchPlaceholder: 'Search items or locations...',
loadMore: 'Load More',
all: 'All',
pending: 'Pending',
completed: 'Success',
high: 'Urgent',
searching: 'Searching server...',
// Dashboard Extra
systemSupport: 'System Support',
instantHelp: 'Instant Help 24/7',
getHelp: 'Get Help',
quickActions: 'Quick Actions',
categories: 'Categories',
latestDiscoveries: 'Latest Discoveries',
account: 'Account',
subscription: 'Subscription',
system: 'System',
explore: 'Explore',
// Profile
personalData: 'PERSONAL DATA',
fullNameLabel: 'Full Name',
editProfile: 'Edit Profile Information',
confirmChanges: 'Confirm Changes',
syncing: 'Syncing...',
preferences: 'PREFERENCES & SECURITY',
darkTheme: 'Dark Mode',
changePass: 'Change Password',
biometrics: 'Biometrics',
language: 'Language',
logout: 'Logout Account',
updateSecurity: 'Update Security',
oldPass: 'Old Password',
newPass: 'New Password',
confirmNew: 'Confirm New',
update: 'Update',
cancel: 'Cancel',
chooseLang: 'Choose Language',
close: 'Close',
confirmLogout: 'Confirm Logout',
areYouSureLogout: 'Are you sure you want to logout from your account?',
// Notifications
notifications: 'Notifications',
markAllRead: 'Mark all as read',
noNotifications: 'No new notifications',
// Help
helpCenter: 'Help Center',
helpSubtitle: 'We are ready to help you with any questions or technical issues.',
emergencyTitle: 'Direct Support',
emergencySubtitle: 'Contact us for urgent assistance',
contactSupport: 'Contact Us',
faqTitle: 'Frequently Asked Questions (FAQ)',
// Help Extra
supportCenter: 'Support Center',
searchDoc: 'Search documentation...',
browseTopics: 'Browse Topics',
popularFaq: 'Popular FAQs',
whatsapp: 'WhatsApp',
emailSupport: 'Email Support',
web: 'Web',
billing: 'Billing',
// Notifications Extra
recentNotifications: 'recent notifications',
},
Indonesian: {
// Auth
login: 'Masuk Sekarang',
register: 'Daftar Sekarang',
createAccount: 'Buat Akun Baru',
registerSubtitle: 'Bergabunglah dengan kami untuk mulai mengelola ruang kerja Anda.',
email: 'Alamat Email',
password: 'Kata Sandi',
fullName: 'Nama Lengkap',
forgotPass: 'Lupa Kata Sandi?',
rememberMe: 'Ingat Saya',
noAccount: 'Belum punya akun? ',
haveAccount: 'Sudah punya akun? ',
loginJust: 'Masuk Saja',
termsText: 'Dengan mendaftar, Anda menyetujui ',
termsLink: 'Ketentuan Layanan',
privacyLink: 'Kebijakan Privasi',
and: ' dan ',
// Auth Extra
signIn: 'Masuk',
emailPlaceholder: 'email@contoh.com',
passwordPlaceholder: '••••••••',
signInNow: 'Masuk Sekarang',
orContinueWith: 'ATAU LANJUTKAN DENGAN',
signUp: 'Daftar',
google: 'Google',
apple: 'Apple',
welcomeBack: 'Selamat datang kembali!',
invalidEmail: 'Alamat email tidak valid',
loginFailed: 'Login gagal. Silakan coba lagi.',
bioConfirm: 'Konfirmasi Identitas',
bioFailed: 'Autentikasi biometrik gagal',
bioSuccess: 'Login Biometrik Berhasil!',
// Auth Extra 2
fillAll: 'Silakan isi semua bidang yang diperlukan',
passMismatch: 'Kata sandi tidak cocok',
accountCreated: 'Akun berhasil dibuat!',
confirmPassword: 'Konfirmasi Kata Sandi',
namePlaceholder: 'John Doe',
registering: 'Membuat akun...',
// Profile Extra
uploadingAvatar: 'Mengunggah foto...',
avatarUpdated: 'Foto profil diperbarui!',
profileUpdated: 'Profil berhasil diperbarui!',
logoutSafe: 'Keluar dengan aman...',
confirmLogout: 'Apakah Anda yakin ingin keluar?',
cancel: 'Batal',
logout: 'Keluar',
accCreated: 'Akun berhasil dibuat!',
regFailed: 'Pendaftaran gagal',
join: 'Bergabunglah dengan',
confirmPass: 'Konfirmasi Kata Sandi',
namePlaceholder: 'Budi Santoso',
// Dashboard
halo: 'Halo',
role: 'Peran Anda: ',
lastStatus: 'Status Terakhir: ',
history: 'Aktivitas Terbaru',
searchPlaceholder: 'Cari item atau lokasi...',
updateNow: 'Perbarui Sekarang',
all: 'Semua',
pending: 'Tertunda',
completed: 'Selesai',
high: 'Penting',
searching: 'Mencari di server...',
// Dashboard Extra
systemSupport: 'Dukungan Sistem',
instantHelp: 'Bantuan Instan 24/7',
getHelp: 'Dapatkan Bantuan',
quickActions: 'Aksi Cepat',
categories: 'Kategori',
latestDiscoveries: 'Penemuan Terbaru',
account: 'Akun',
subscription: 'Langganan',
system: 'Sistem',
explore: 'Jelajahi',
// Profile
personalData: 'DATA PRIBADI',
fullNameLabel: 'Nama Lengkap',
editProfile: 'Ubah Informasi Profil',
confirmChanges: 'Simpan Perubahan',
syncing: 'Menyinkronkan...',
preferences: 'PREFERENSI & KEAMANAN',
darkTheme: 'Mode Gelap',
changePass: 'Ubah Kata Sandi',
biometrics: 'Biometrik',
language: 'Bahasa',
logout: 'Keluar Akun',
updateSecurity: 'Perbarui Keamanan',
oldPass: 'Sandi Lama',
newPass: 'Sandi Baru',
confirmNew: 'Konfirmasi Sandi Baru',
update: 'Perbarui',
cancel: 'Batal',
chooseLang: 'Pilih Bahasa',
close: 'Tutup',
confirmLogout: 'Konfirmasi Keluar',
areYouSureLogout: 'Apakah Anda yakin ingin keluar dari akun Anda?',
// Notifications
notifications: 'Pemberitahuan',
markAllRead: 'Tandai semua dibaca',
noNotifications: 'Tidak ada pemberitahuan baru',
// Notifications Extra
recentNotifications: 'pemberitahuan terbaru',
// Help
helpCenter: 'Pusat Bantuan',
helpSubtitle: 'Kami siap membantu Anda dengan pertanyaan atau kendala teknis.',
emergencyTitle: 'Dukungan Langsung',
emergencySubtitle: 'Hubungi kami untuk bantuan mendesak',
contactSupport: 'Hubungi Kami',
faqTitle: 'Pertanyaan Umum (FAQ)',
// Help Extra
supportCenter: 'Pusat Dukungan',
searchDoc: 'Cari dokumentasi...',
browseTopics: 'Telusuri Topik',
popularFaq: 'FAQ Populer',
whatsapp: 'WhatsApp',
emailSupport: 'Dukungan Email',
web: 'Web',
billing: 'Tagihan',
}
};
interface LanguageContextType {
language: LanguageType;
setLanguage: (lang: LanguageType) => void;
t: any;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export function LanguageProvider({ children }: { children: React.ReactNode }) {
const [language, setRawLanguage] = useState<LanguageType>('English');
const { config } = useAppConfig();
useEffect(() => {
loadLanguage();
}, []);
const loadLanguage = async () => {
const saved = await storage.get('pref_language');
if (saved === 'English' || saved === 'Indonesian') {
setRawLanguage(saved);
}
};
const setLanguage = async (newLang: LanguageType) => {
setRawLanguage(newLang);
await storage.save('pref_language', newLang);
};
// Merge static translations with dynamic ones from Laravel
const t = {
...translations[language],
...(config?.localization?.[language] || {})
};
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);
}
export function useTranslation() {
const context = useContext(LanguageContext);
if (context === undefined) {
return {
language: 'English',
setLanguage: () => {},
t: translations.English
};
}
return context;
}
+40
View File
@@ -0,0 +1,40 @@
import React, { createContext, useContext, useState } from 'react';
import { useAuth } from './AuthContext';
import { ApiService } from '../services/api';
interface RefreshContextType {
refreshing: boolean;
refreshAll: () => Promise<void>;
}
const RefreshContext = createContext<RefreshContextType | undefined>(undefined);
export function RefreshProvider({ children }: { children: React.ReactNode }) {
const [refreshing, setRefreshing] = useState(false);
const { syncUser } = useAuth();
const refreshAll = async () => {
setRefreshing(true);
try {
// Sync User Data
await syncUser();
// You can add more global syncs here (e.g., config, notifications)
} catch (e) {
console.error('Global refresh failed', e);
} finally {
setRefreshing(false);
}
};
return (
<RefreshContext.Provider value={{ refreshing, refreshAll }}>
{children}
</RefreshContext.Provider>
);
}
export function useRefresh() {
const context = useContext(RefreshContext);
if (context === undefined) throw new Error('useRefresh must be used within a RefreshProvider');
return context;
}
+73
View File
@@ -0,0 +1,73 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useColorScheme, Platform } from 'react-native';
import { Theme } from '../constants/theme';
import { storage } from '../utils/storage';
import { useAppConfig } from './ConfigContext';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
mode: ThemeMode;
setMode: (mode: ThemeMode) => void;
colors: typeof Theme.dark;
isDark: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Storage helper is now imported from utils/storage
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemColorScheme = useColorScheme();
const [mode, setRawMode] = useState<ThemeMode>('light');
const { config } = useAppConfig();
useEffect(() => {
loadTheme();
}, []);
const loadTheme = async () => {
const savedMode = await storage.get('theme_mode');
if (savedMode) {
setRawMode(savedMode as ThemeMode);
}
};
const setMode = async (newMode: ThemeMode) => {
setRawMode(newMode);
await storage.save('theme_mode', newMode);
};
const isDark = mode === 'system' ? systemColorScheme === 'dark' : mode === 'dark';
// Layer admin-controlled brand colors and logo on top of the static design tokens
// so consumers always receive the full palette (surfaceLight, glass, accent, etc).
const baseColors = isDark ? Theme.dark : Theme.light;
const colors = {
...baseColors,
primary: config?.branding?.theme_color_primary || baseColors.primary,
accent: config?.branding?.theme_color_primary || baseColors.accent,
secondary: config?.branding?.theme_color_secondary || baseColors.secondary,
logo: config?.branding?.logo_url || null,
} as typeof Theme.dark & { logo: string | null };
return (
<ThemeContext.Provider value={{ mode, setMode, colors, isDark }}>
{children}
</ThemeContext.Provider>
);
}
export function useAppTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
// Fallback safely instead of crashing — return the full design-token palette
return {
mode: 'light',
setMode: () => {},
isDark: false,
colors: { ...Theme.light, logo: null }
};
}
return context;
}
+139
View File
@@ -0,0 +1,139 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import { View, Text, StyleSheet, Animated, Platform, Dimensions } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppTheme } from './ThemeContext';
interface ToastContextType {
showToast: (message: string, type?: 'success' | 'error' | 'info') => void;
}
const { width } = Dimensions.get('window');
const LIME = '#C6F135';
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const { colors, isDark } = useAppTheme();
const [toast, setToast] = useState<{ message: string, type: string } | null>(null);
const translateY = useRef(new Animated.Value(-120)).current;
const opacity = useRef(new Animated.Value(0)).current;
const showToast = useCallback((message: string, type: 'success' | 'error' | 'info' = 'info') => {
setToast({ message, type });
translateY.setValue(-120);
opacity.setValue(0);
Animated.sequence([
Animated.parallel([
Animated.spring(translateY, { toValue: Platform.OS === 'ios' ? 60 : 40, useNativeDriver: true, friction: 9, tension: 50 }),
Animated.timing(opacity, { toValue: 1, duration: 400, useNativeDriver: true })
]),
Animated.delay(2800),
Animated.parallel([
Animated.timing(translateY, { toValue: -120, duration: 300, useNativeDriver: true }),
Animated.timing(opacity, { toValue: 0, duration: 300, useNativeDriver: true })
])
]).start(() => setToast(null));
}, []);
// Theme configuration for the toast
const getMeta = () => {
if (!toast) return { icon: 'information', color: LIME };
switch(toast.type) {
case 'success': return { icon: 'check-circle', color: LIME };
case 'error': return { icon: 'alert-circle', color: '#EF4444' };
default: return { icon: 'information', color: '#3B82F6' };
}
};
const meta = getMeta();
const toastBg = isDark ? '#1A1A1A' : '#1A1A1A'; // Solid dark toast for both modes is more premium
const border = isDark ? '#2A2A2A' : '#333';
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{toast && (
<Animated.View
style={[
styles.container,
{
opacity,
transform: [{ translateY }],
zIndex: 10000,
}
]}
pointerEvents="none"
>
<View style={[
styles.toast,
{
backgroundColor: toastBg,
borderColor: border,
}
]}>
<View style={[styles.iconBox, { backgroundColor: `${meta.color}20` }]}>
<MaterialCommunityIcons
name={meta.icon as any}
size={22}
color={meta.color}
/>
</View>
<Text style={styles.text}>{toast.message}</Text>
</View>
</Animated.View>
)}
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (context === undefined) {
return {
showToast: (msg: string) => console.log('Toast (Fallback):', msg)
};
}
return context;
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
left: 20,
right: 20,
alignItems: 'center',
},
toast: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 20,
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 15 },
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 12,
width: '100%',
maxWidth: 450,
},
iconBox: {
width: 42,
height: 42,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
text: {
marginLeft: 14,
fontSize: 14,
fontFamily: 'Outfit_700Bold',
color: '#FFFFFF',
flexShrink: 1,
},
});
+27
View File
@@ -0,0 +1,27 @@
{
"cli": {
"version": ">= 18.9.1",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true,
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {}
}
}
+10
View File
@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
]);
+1
View File
@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
+35
View File
@@ -0,0 +1,35 @@
import { useState } from 'react';
export function useForm<T>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const handleChange = (name: keyof T, value: string) => {
setValues({
...values,
[name]: value,
});
// Clear error when user types
if (errors[name]) {
setErrors({
...errors,
[name]: undefined,
});
}
};
const setFieldError = (name: keyof T, error: string) => {
setErrors({
...errors,
[name]: error,
});
};
return {
values,
errors,
handleChange,
setFieldError,
setValues,
};
}
+13790
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
{
"name": "mobile",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start --offline",
"dev": "expo start --offline",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android --offline",
"ios": "expo run:ios --offline",
"web": "expo start --web --offline",
"lint": "expo lint",
"postinstall": "bash scripts/apply-patches.sh"
},
"dependencies": {
"@expo-google-fonts/outfit": "^0.4.3",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "11.4.1",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.34",
"expo-blur": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.11",
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.12",
"expo-local-authentication": "~17.0.8",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-store-review": "~9.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.11",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-aware-scroll-view": "^0.9.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"patch-package": "^8.0.1",
"typescript": "~5.9.2"
},
"private": true
}
@@ -0,0 +1,147 @@
diff --git a/node_modules/react-native-reanimated/android/.classpath b/node_modules/react-native-reanimated/android/.classpath
new file mode 100644
index 0000000..bbe97e5
--- /dev/null
+++ b/node_modules/react-native-reanimated/android/.classpath
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<classpath>
+ <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17/"/>
+ <classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
+ <classpathentry kind="output" path="bin/default"/>
+</classpath>
diff --git a/node_modules/react-native-reanimated/android/.gradle/7.5.1/checksums/checksums.lock b/node_modules/react-native-reanimated/android/.gradle/7.5.1/checksums/checksums.lock
new file mode 100644
index 0000000..8a17076
Binary files /dev/null and b/node_modules/react-native-reanimated/android/.gradle/7.5.1/checksums/checksums.lock differ
diff --git a/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileChanges/last-build.bin b/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileChanges/last-build.bin
new file mode 100644
index 0000000..f76dd23
Binary files /dev/null and b/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileChanges/last-build.bin differ
diff --git a/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileHashes/fileHashes.lock b/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileHashes/fileHashes.lock
new file mode 100644
index 0000000..40eb925
Binary files /dev/null and b/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileHashes/fileHashes.lock differ
diff --git a/node_modules/react-native-reanimated/android/.gradle/7.5.1/gc.properties b/node_modules/react-native-reanimated/android/.gradle/7.5.1/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/react-native-reanimated/android/.gradle/vcs-1/gc.properties b/node_modules/react-native-reanimated/android/.gradle/vcs-1/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/react-native-reanimated/android/.project b/node_modules/react-native-reanimated/android/.project
new file mode 100644
index 0000000..c835873
--- /dev/null
+++ b/node_modules/react-native-reanimated/android/.project
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<projectDescription>
+ <name>react-native-reanimated</name>
+ <comment>Project react-native-reanimated created by Buildship.</comment>
+ <projects>
+ </projects>
+ <buildSpec>
+ <buildCommand>
+ <name>org.eclipse.jdt.core.javabuilder</name>
+ <arguments>
+ </arguments>
+ </buildCommand>
+ <buildCommand>
+ <name>org.eclipse.buildship.core.gradleprojectbuilder</name>
+ <arguments>
+ </arguments>
+ </buildCommand>
+ </buildSpec>
+ <natures>
+ <nature>org.eclipse.jdt.core.javanature</nature>
+ <nature>org.eclipse.buildship.core.gradleprojectnature</nature>
+ </natures>
+ <filteredResources>
+ <filter>
+ <id>1777685576274</id>
+ <name></name>
+ <type>30</type>
+ <matcher>
+ <id>org.eclipse.core.resources.regexFilterMatcher</id>
+ <arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
+ </matcher>
+ </filter>
+ </filteredResources>
+</projectDescription>
diff --git a/node_modules/react-native-reanimated/android/.settings/org.eclipse.buildship.core.prefs b/node_modules/react-native-reanimated/android/.settings/org.eclipse.buildship.core.prefs
new file mode 100644
index 0000000..1675490
--- /dev/null
+++ b/node_modules/react-native-reanimated/android/.settings/org.eclipse.buildship.core.prefs
@@ -0,0 +1,2 @@
+connection.project.dir=../../../android
+eclipse.preferences.version=1
diff --git a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/ReanimatedPackage.java b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/ReanimatedPackage.java
index 112f758..39b4867 100644
--- a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/ReanimatedPackage.java
+++ b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/ReanimatedPackage.java
@@ -69,7 +69,7 @@ public class ReanimatedPackage extends TurboReactPackage implements ReactPackage
private UIManagerModule createUIManager(final ReactApplicationContext reactContext) {
ReactMarker.logMarker(CREATE_UI_MANAGER_MODULE_START);
- Systrace.beginSection(Systrace.TRACE_TAG_REACT_JAVA_BRIDGE, "createUIManagerModule");
+ Systrace.beginSection(0, "createUIManagerModule");
final ReactInstanceManager reactInstanceManager = getReactInstanceManager(reactContext);
List<ViewManager> viewManagers = reactInstanceManager.getOrCreateViewManagers(reactContext);
int minTimeLeftInFrameForNonBatchedOperationMs = -1;
@@ -77,7 +77,7 @@ public class ReanimatedPackage extends TurboReactPackage implements ReactPackage
return ReanimatedUIManagerFactory.create(
reactContext, viewManagers, minTimeLeftInFrameForNonBatchedOperationMs);
} finally {
- Systrace.endSection(Systrace.TRACE_TAG_REACT_JAVA_BRIDGE);
+ Systrace.endSection(0);
ReactMarker.logMarker(CREATE_UI_MANAGER_MODULE_END);
}
}
diff --git a/node_modules/react-native-reanimated/android/src/reactNativeVersionPatch/BorderRadiiDrawableUtils/latest/com/swmansion/reanimated/BorderRadiiDrawableUtils.java b/node_modules/react-native-reanimated/android/src/reactNativeVersionPatch/BorderRadiiDrawableUtils/latest/com/swmansion/reanimated/BorderRadiiDrawableUtils.java
index c850777..8af7c09 100644
--- a/node_modules/react-native-reanimated/android/src/reactNativeVersionPatch/BorderRadiiDrawableUtils/latest/com/swmansion/reanimated/BorderRadiiDrawableUtils.java
+++ b/node_modules/react-native-reanimated/android/src/reactNativeVersionPatch/BorderRadiiDrawableUtils/latest/com/swmansion/reanimated/BorderRadiiDrawableUtils.java
@@ -18,7 +18,7 @@ public class BorderRadiiDrawableUtils {
return defaultValue;
}
Rect bounds = view.getBackground().getBounds();
- return length.resolve(bounds.width(), bounds.height()).toPixelFromDIP().getHorizontal();
+ return length.resolve((float) bounds.width());
}
public static ReactNativeUtils.BorderRadii getBorderRadii(View view) {
diff --git a/node_modules/react-native-reanimated/lib/module/createAnimatedComponent/createAnimatedComponent.js b/node_modules/react-native-reanimated/lib/module/createAnimatedComponent/createAnimatedComponent.js
index 69682cb..6f6573f 100644
--- a/node_modules/react-native-reanimated/lib/module/createAnimatedComponent/createAnimatedComponent.js
+++ b/node_modules/react-native-reanimated/lib/module/createAnimatedComponent/createAnimatedComponent.js
@@ -54,7 +54,7 @@ function onlyAnimatedStyles(styles) {
let id = 0;
export function createAnimatedComponent(Component, options) {
- invariant(typeof Component !== 'function' || Component.prototype && Component.prototype.isReactComponent, `Looks like you're passing a function component \`${Component.name}\` to \`createAnimatedComponent\` function which supports only class components. Please wrap your function component with \`React.forwardRef()\` or use a class component instead.`);
+ // invariant(typeof Component !== 'function' || Component.prototype && Component.prototype.isReactComponent, `Looks like you're passing a function component \`${Component.name}\` to \`createAnimatedComponent\` function which supports only class components. Please wrap your function component with \`React.forwardRef()\` or use a class component instead.`);
class AnimatedComponent extends React.Component {
_styles = null;
_isFirstRender = true;
diff --git a/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx b/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx
index e101e03..8b405b2 100644
--- a/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx
+++ b/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx
@@ -111,11 +111,11 @@ export function createAnimatedComponent(
Component: ComponentType<InitialComponentProps>,
options?: Options<InitialComponentProps>
): any {
- invariant(
- typeof Component !== 'function' ||
- (Component.prototype && Component.prototype.isReactComponent),
- `Looks like you're passing a function component \`${Component.name}\` to \`createAnimatedComponent\` function which supports only class components. Please wrap your function component with \`React.forwardRef()\` or use a class component instead.`
- );
+ // invariant(
+ // typeof Component !== 'function' ||
+ // (Component.prototype && Component.prototype.isReactComponent),
+ // `Looks like you're passing a function component \`${Component.name}\` to \`createAnimatedComponent\` function which supports only class components. Please wrap your function component with \`React.forwardRef()\` or use a class component instead.`
+ // );
class AnimatedComponent
extends React.Component<AnimatedComponentProps<InitialComponentProps>>
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# apply-patches.sh - Manual patch script for react-native-reanimated RN 0.81 compatibility
set -e
REANIMATED_DIR="node_modules/react-native-reanimated"
echo "Applying patches for react-native-reanimated..."
# --- Patch 1: ReanimatedPackage.java ---
REANIMATED_PKG="$REANIMATED_DIR/android/src/main/java/com/swmansion/reanimated/ReanimatedPackage.java"
if grep -q "Systrace\.TRACE_TAG_REACT_JAVA_BRIDGE" "$REANIMATED_PKG" 2>/dev/null; then
sed -i 's/Systrace\.beginSection(Systrace\.TRACE_TAG_REACT_JAVA_BRIDGE,/Systrace.beginSection(0,/g' "$REANIMATED_PKG"
sed -i 's/Systrace\.endSection(Systrace\.TRACE_TAG_REACT_JAVA_BRIDGE)/Systrace.endSection(0)/g' "$REANIMATED_PKG"
echo "✅ Patched ReanimatedPackage.java (TRACE_TAG_REACT_JAVA_BRIDGE)"
fi
# --- Patch 2: BorderRadiiDrawableUtils.java ---
BORDER_UTIL="$REANIMATED_DIR/android/src/reactNativeVersionPatch/BorderRadiiDrawableUtils/latest/com/swmansion/reanimated/BorderRadiiDrawableUtils.java"
if grep -q "resolve(bounds.width(), bounds.height()).toPixelFromDIP().getHorizontal()" "$BORDER_UTIL" 2>/dev/null; then
sed -i 's/return length\.resolve(bounds\.width(), bounds\.height())\.toPixelFromDIP()\.getHorizontal();/return length.resolve((float) bounds.width());/g' "$BORDER_UTIL"
echo "✅ Patched BorderRadiiDrawableUtils.java (LengthPercentage.resolve)"
fi
# Note: TSX patches are handled separately or via more precise tools to avoid corruption.
echo "All native patches applied successfully!"
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);
+362
View File
@@ -0,0 +1,362 @@
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';
import { storage } from '../utils/storage';
/**
* Dynamic API Base URL
*/
/**
* Dynamic API Base URL
*
* PRIORITY:
* 1. MANUAL_API_IP (Explicit override for development)
* 2. Cached Remote Config (Production backend-controlled)
* 3. Expo Constants / app.json extra.apiUrl
* 4. Auto-detected Host IP (Expo Go)
* 5. Platform-specific defaults
*/
const MANUAL_API_IP = ''; // Set this to '192.168.x.x' to override
const getBaseUrl = async () => {
const version = 'v1';
const apiPath = `/api/${version}`;
// 1. Manual Override (Highest Priority for specific IP needs)
if (MANUAL_API_IP) {
return `http://${MANUAL_API_IP}:8000${apiPath}`;
}
// 2. Try to get from cached config (Backend-controlled)
const cachedConfigStr = await storage.get('cached_mobile_config');
if (cachedConfigStr && !__DEV__) {
try {
const config = JSON.parse(cachedConfigStr);
const remoteBase = config?.connectivity?.api_base_url;
if (remoteBase && !remoteBase.includes('biiproject.com')) {
return `${remoteBase}${apiPath}`;
}
} catch {}
}
// 3. Try Expo Constants / app.json extra (Developer-controlled)
const extraApiUrl = Constants.expoConfig?.extra?.apiUrl;
if (extraApiUrl) {
const cleanBase = extraApiUrl.endsWith('/') ? extraApiUrl.slice(0, -1) : extraApiUrl;
return cleanBase.includes('/api/') ? cleanBase : `${cleanBase}${apiPath}`;
}
// 4. Auto-detect IP from Expo Go (Highest Priority for Local Development)
const hostUri = Constants.expoConfig?.hostUri ||
(Constants as any).manifest2?.extra?.expoGoConfig?.debuggerHost ||
(Constants as any).manifest?.debuggerHost;
if (hostUri) {
const ip = hostUri.split(':')[0];
if (ip && ip !== 'localhost' && ip !== '127.0.0.1' && !ip.startsWith('10.0.2.2')) {
if (!ip.includes('exp.direct') && !ip.includes('expo.dev')) {
return `http://${ip}:8000${apiPath}`;
}
}
}
// 5. Fallback for Web
if (Platform.OS === 'web') {
if (typeof window !== 'undefined' && window.location) {
const hostname = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? 'localhost'
: window.location.hostname;
return `http://${hostname}:8000${apiPath}`;
}
}
// 6. Absolute Fallback (Laravel Sail default tunnel or localhost)
return `http://zqfwerzr7b.laravel-sail.site:8080${apiPath}`;
};
const getUrl = async (path: string) => {
const baseUrl = await getBaseUrl();
const cleanPath = path.startsWith('/') ? path : `/${path}`;
const finalUrl = `${baseUrl}${cleanPath}`;
if (__DEV__) {
console.log(`[API] Req: ${finalUrl}`);
}
return finalUrl;
};
// Helper for authorized requests
const getAuthHeaders = async () => {
const token = await storage.get('user_token');
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${token}`,
};
};
// Helper for fetching with timeout and retry
const fetchWithTimeout = async (url: string, options: any = {}) => {
const cachedConfigStr = await storage.get('cached_mobile_config');
let config: any = null;
try { config = cachedConfigStr ? JSON.parse(cachedConfigStr) : null; } catch {}
let timeout = config?.connectivity?.api_timeout_ms || 30000;
let retryCount = config?.connectivity?.api_retry_count || 0;
let lastError: any;
for (let i = 0; i <= retryCount; i++) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(id);
if (response.status >= 500 && i < retryCount) {
throw new Error(`Server Error ${response.status}`);
}
return response;
} catch (error: any) {
clearTimeout(id);
lastError = error;
// Only retry on network errors or 5xx server errors
const isNetworkError = error.message === 'Network request failed' || error.name === 'AbortError';
const isServerError = error.message.includes('Server Error');
if (i < retryCount && (isNetworkError || isServerError)) {
// Exponential backoff: 1s, 2s, 4s...
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (error.name === 'AbortError') {
throw new Error('Request timed out. Please check your connection.');
}
if (error.message === 'Network request failed') {
throw new Error('Cannot connect to server. Please check your internet or VPN.');
}
throw error;
}
}
throw lastError;
};
export const ApiService = {
login: async (email: string, pass: string) => {
const response = await fetchWithTimeout(await getUrl('/login'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ email, password: pass }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Login failed');
return data;
},
forgotPassword: async (email: string) => {
const response = await fetchWithTimeout(await getUrl('/forgot-password'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Request failed');
return data;
},
register: async (name: string, email: string, pass: string) => {
const response = await fetchWithTimeout(await getUrl('/register'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ name, email, password: pass }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Registration failed');
return data;
},
getUser: async () => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/user'), {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Failed to fetch user');
return data;
},
updateAvatar: async (uri: string) => {
const headers = await getAuthHeaders();
// FormData needs special handling for Content-Type
delete (headers as any)['Content-Type'];
const formData = new FormData();
// Ensure URI is properly formatted for Android
const photoUri = Platform.OS === 'android' ? (uri.startsWith('file://') ? uri : `file://${uri}`) : uri;
const filename = uri.split('/').pop() || 'avatar.jpg';
const match = /\.(\w+)$/.exec(filename);
const type = match ? `image/${match[1]}` : `image/jpeg`;
formData.append('avatar', {
uri: photoUri,
name: filename,
type,
} as any);
const response = await fetchWithTimeout(await getUrl('/profile/avatar'), {
method: 'POST',
headers,
body: formData,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Avatar update failed');
return data;
},
updateProfile: async (name: string, email: string) => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/profile/update'), {
method: 'POST',
headers,
body: JSON.stringify({ name, email }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Update failed');
return data;
},
updatePassword: async (current: string, newPass: string, confirmPass: string) => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/profile/password'), {
method: 'POST',
headers,
body: JSON.stringify({
current_password: current,
password: newPass,
password_confirmation: confirmPass
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Password update failed');
return data;
},
deleteAccount: async () => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/profile/delete'), {
method: 'DELETE',
headers,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Account deletion failed');
return data;
},
getDashboardData: async () => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/dashboard'), {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Failed to fetch dashboard');
return data;
},
getAppConfig: async () => {
const response = await fetchWithTimeout(await getUrl('/app-config'), {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Failed to fetch config');
return data;
},
syncMobileConfig: async (etag?: string | null) => {
const platform = Platform.OS;
let version = '2.0.0';
try {
const cachedConfigStr = await storage.get('cached_mobile_config');
if (cachedConfigStr) {
const cached = JSON.parse(cachedConfigStr);
if (cached?.app_updates?.app_version) version = cached.app_updates.app_version;
}
} catch {}
const headers: any = { 'Accept': 'application/json' };
if (etag) headers['If-None-Match'] = etag;
const response = await fetchWithTimeout(`${await getUrl('/mobile/sync')}?p=${platform}&v=${version}`, {
method: 'GET',
headers,
});
if (response.status === 304) {
return { status: 'not_modified' };
}
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Failed to sync template');
return {
...data,
etag: response.headers.get('ETag')
};
},
reportError: async (message: string, level: string = 'error', context: any = {}) => {
try {
const cachedConfigStr = await storage.get('cached_mobile_config');
if (cachedConfigStr) {
const config = JSON.parse(cachedConfigStr);
// 1. Check if reporting is enabled
if (config.analytics_system?.crashlytics_enabled === false) return false;
// 2. Check Log Level (Priority: critical > error > warning > info > debug)
const levels: Record<string, number> = { debug: 0, info: 1, warning: 2, error: 3, critical: 4 };
const minLevel = config.analytics_system?.log_level || 'error';
if ((levels[level] || 0) < (levels[minLevel] || 3)) return false;
}
const token = await storage.get('user_token');
const headers: any = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = await getUrl('/mobile/log');
fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ level, message, context }),
}).catch(() => {});
} catch {}
return true;
},
registerDeviceToken: async (token: string, platform: string) => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/devices/register'), {
method: 'POST',
headers,
body: JSON.stringify({ token, platform, type: 'expo' }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Token registration failed');
return data;
},
};
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}
+40
View File
@@ -0,0 +1,40 @@
import { storage } from './storage';
import * as StoreReview from 'expo-store-review';
const ACTIONS_COUNT_KEY = 'user_actions_count';
const LAST_PROMPT_DATE_KEY = 'last_review_prompt_date';
export const ActionTracker = {
/**
* Increment action count and check if we should show review prompt
*/
async trackAction(minActions: number = 10, enabled: boolean = true) {
if (!enabled) return;
try {
const currentCountStr = await storage.get(ACTIONS_COUNT_KEY);
const currentCount = parseInt(currentCountStr || '0', 10) + 1;
await storage.save(ACTIONS_COUNT_KEY, currentCount.toString());
if (currentCount >= minActions) {
const lastPrompt = await storage.get(LAST_PROMPT_DATE_KEY);
const now = new Date().getTime();
// Only prompt once every 30 days
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (!lastPrompt || (now - parseInt(lastPrompt, 10)) > thirtyDays) {
if (await StoreReview.isAvailableAsync()) {
await StoreReview.requestReview();
await storage.save(LAST_PROMPT_DATE_KEY, now.toString());
// Reset counter after successful prompt
await storage.save(ACTIONS_COUNT_KEY, '0');
}
}
}
} catch (error) {
console.warn('[ActionTracker] Error:', error);
}
}
};
+34
View File
@@ -0,0 +1,34 @@
import { ApiService } from '../services/api';
class Logger {
private logs: string[] = [];
private maxLogs = 50;
log(message: string, type: 'info' | 'error' | 'sync' | 'success' = 'info') {
const timestamp = new Date().toLocaleTimeString();
const entry = `[${timestamp}] [${type.toUpperCase()}] ${message}`;
this.logs.unshift(entry);
if (this.logs.length > this.maxLogs) {
this.logs.pop();
}
if (type === 'error') {
ApiService.reportError(message, 'error').catch(() => {});
}
if (__DEV__) {
console.log(entry);
}
}
getLogs() {
return this.logs;
}
clear() {
this.logs = [];
}
}
export const DebugLogger = new Logger();
+96
View File
@@ -0,0 +1,96 @@
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
/**
* 🚀 Unified Storage System for biiproject
* Handles both Native (AsyncStorage/SecureStore) and Web (LocalStorage) seamlessly.
*/
const SENSITIVE_KEYS = ['user_token', 'saved_pass', 'biometric_credentials'];
// In-memory fallback for environments where storage is unavailable (e.g. some web modes or broken native modules)
const memoryStorage: Record<string, string> = {};
export const storage = {
save: async (key: string, value: string) => {
try {
if (Platform.OS === 'web') {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
} else {
memoryStorage[key] = value;
}
} else {
if (SENSITIVE_KEYS.includes(key)) {
// SecureStore might fail if biometrics are not configured or on some Android versions
try {
await SecureStore.setItemAsync(key, value);
} catch (e) {
console.warn(`[Storage] SecureStore failed for ${key}, falling back to AsyncStorage`, e);
await AsyncStorage.setItem(key, value);
}
} else {
await AsyncStorage.setItem(key, value);
}
}
return true;
} catch (error) {
console.error(`Storage Save Error [${key}]:`, error);
memoryStorage[key] = value; // Last resort
return false;
}
},
get: async (key: string) => {
try {
if (Platform.OS === 'web') {
if (typeof localStorage !== 'undefined') {
return localStorage.getItem(key);
}
return memoryStorage[key] || null;
} else {
if (SENSITIVE_KEYS.includes(key)) {
try {
const val = await SecureStore.getItemAsync(key);
if (val) return val;
} catch (e) {
console.warn(`[Storage] SecureStore read failed for ${key}`, e);
}
// Check fallback
return await AsyncStorage.getItem(key);
} else {
return await AsyncStorage.getItem(key);
}
}
} catch (error) {
console.warn(`Storage Get Error [${key}]:`, error);
return memoryStorage[key] || null;
}
},
remove: async (key: string) => {
try {
if (Platform.OS === 'web') {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
}
delete memoryStorage[key];
} else {
if (SENSITIVE_KEYS.includes(key)) {
try {
await SecureStore.deleteItemAsync(key);
} catch (e) {
console.warn(`[Storage] SecureStore delete failed for ${key}`, e);
}
await AsyncStorage.removeItem(key);
} else {
await AsyncStorage.removeItem(key);
}
}
return true;
} catch (error) {
console.error(`Storage Remove Error [${key}]:`, error);
return false;
}
}
};
+331
View File
@@ -0,0 +1,331 @@
parameters:
ignoreErrors:
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$name\.$#'
identifier: property.notFound
count: 1
path: app/Events/ActivityLogCreated.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$email\.$#'
identifier: property.notFound
count: 2
path: app/Http/Controllers/AccessControl/ActionLogController.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$name\.$#'
identifier: property.notFound
count: 3
path: app/Http/Controllers/AccessControl/ActionLogController.php
-
message: '#^Call to function is_array\(\) with Illuminate\\Support\\Collection\|null will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: app/Http/Controllers/AccessControl/ActionLogController.php
-
message: '#^Using nullsafe property access "\?\-\>email" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 2
path: app/Http/Controllers/AccessControl/ActionLogController.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 3
path: app/Http/Controllers/AccessControl/ActionLogController.php
-
message: '#^Access to an undefined property App\\Models\\Permission\:\:\$is_active\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/AccessControl/PermissionManagementController.php
-
message: '#^Relation ''creator'' is not found in App\\Models\\Permission model\.$#'
identifier: larastan.relationExistence
count: 3
path: app/Http/Controllers/AccessControl/PermissionManagementController.php
-
message: '#^Relation ''updater'' is not found in App\\Models\\Permission model\.$#'
identifier: larastan.relationExistence
count: 3
path: app/Http/Controllers/AccessControl/PermissionManagementController.php
-
message: '#^Access to an undefined property App\\Models\\Role\:\:\$is_active\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/AccessControl/RoleManagementController.php
-
message: '#^Relation ''creator'' is not found in App\\Models\\Role model\.$#'
identifier: larastan.relationExistence
count: 3
path: app/Http/Controllers/AccessControl/RoleManagementController.php
-
message: '#^Relation ''updater'' is not found in App\\Models\\Role model\.$#'
identifier: larastan.relationExistence
count: 3
path: app/Http/Controllers/AccessControl/RoleManagementController.php
-
message: '#^Relation ''creator'' is not found in App\\Models\\User model\.$#'
identifier: larastan.relationExistence
count: 3
path: app/Http/Controllers/AccessControl/UserManagementController.php
-
message: '#^Relation ''updater'' is not found in App\\Models\\User model\.$#'
identifier: larastan.relationExistence
count: 3
path: app/Http/Controllers/AccessControl/UserManagementController.php
-
message: '#^Called ''pluck'' on Laravel collection, but could have been retrieved as a query\.$#'
identifier: larastan.noUnnecessaryCollectionCall
count: 1
path: app/Http/Controllers/Admin/MobileSettingController.php
-
message: '#^Call to an undefined method Illuminate\\Cookie\\CookieJar\:\:get\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Controllers/Auth/AuthenticatedSessionController.php
-
message: '#^Access to an undefined property Laravel\\Socialite\\Contracts\\User\:\:\$avatar\.$#'
identifier: property.notFound
count: 2
path: app/Http/Controllers/Auth/SocialAuthController.php
-
message: '#^Access to an undefined property Laravel\\Socialite\\Contracts\\User\:\:\$email\.$#'
identifier: property.notFound
count: 3
path: app/Http/Controllers/Auth/SocialAuthController.php
-
message: '#^Access to an undefined property Laravel\\Socialite\\Contracts\\User\:\:\$id\.$#'
identifier: property.notFound
count: 3
path: app/Http/Controllers/Auth/SocialAuthController.php
-
message: '#^Parameter \#1 \$user of class Illuminate\\Auth\\Events\\Verified constructor expects Illuminate\\Contracts\\Auth\\MustVerifyEmail, App\\Models\\User\|null given\.$#'
identifier: argument.type
count: 1
path: app/Http/Controllers/Auth/VerifyEmailController.php
-
message: '#^Strict comparison using \=\=\= between ''about''\|''help''\|''security''\|''tos'' and ''pdp'' will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: app/Http/Controllers/LegalController.php
-
message: '#^Access to an undefined property Laravel\\Socialite\\Contracts\\User\:\:\$refreshToken\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/SystemSettings/BackupRestoreController.php
-
message: '#^Call to an undefined method Laravel\\Socialite\\Contracts\\Provider\:\:scopes\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Http/Controllers/SystemSettings/BackupRestoreController.php
-
message: '#^Access to an undefined property App\\Models\\Notification\:\:\$personal_read_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/SystemSettings/NotificationCenterController.php
-
message: '#^Parameter \#1 \$notification of class App\\Events\\NotificationSent constructor expects App\\Models\\Notification, Illuminate\\Database\\Eloquent\\Model given\.$#'
identifier: argument.type
count: 1
path: app/Http/Controllers/SystemSettings/NotificationCenterController.php
-
message: '#^Call to method close\(\) on an unknown class SAPNWRFC\\Connection\.$#'
identifier: class.notFound
count: 1
path: app/Http/Controllers/SystemSettings/SystemConfigController.php
-
message: '#^Call to method getAttributes\(\) on an unknown class SAPNWRFC\\Connection\.$#'
identifier: class.notFound
count: 1
path: app/Http/Controllers/SystemSettings/SystemConfigController.php
-
message: '#^Instantiated class SAPNWRFC\\Connection not found\.$#'
identifier: class.notFound
count: 1
path: app/Http/Controllers/SystemSettings/SystemConfigController.php
-
message: '#^Method App\\Http\\Controllers\\WebAuthn\\WebAuthnRegisterController\:\:register\(\) should return Illuminate\\Http\\Response but returns Illuminate\\Http\\JsonResponse\.$#'
identifier: return.type
count: 1
path: app/Http/Controllers/WebAuthn/WebAuthnRegisterController.php
-
message: '#^Variable \$updates in empty\(\) always exists and is not falsy\.$#'
identifier: empty.variable
count: 1
path: app/Http/Requests/SystemSettings/UpdateSystemConfigRequest.php
-
message: '#^Parameter \#1 \$modelOrId of method Spatie\\Activitylog\\ActivityLogger\:\:causedBy\(\) expects Illuminate\\Database\\Eloquent\\Model\|int\|string\|null, Illuminate\\Contracts\\Auth\\Authenticatable\|null given\.$#'
identifier: argument.type
count: 1
path: app/Listeners/LogFailedLogin.php
-
message: '#^Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Listeners/LogLogout.php
-
message: '#^Negated boolean expression is always false\.$#'
identifier: booleanNot.alwaysFalse
count: 1
path: app/Listeners/LogLogout.php
-
message: '#^Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: app/Listeners/LogSuccessfulLogin.php
-
message: '#^Access to an undefined property App\\Models\\Permission\:\:\$created_by\.$#'
identifier: property.notFound
count: 1
path: app/Observers/PermissionObserver.php
-
message: '#^Access to an undefined property App\\Models\\Permission\:\:\$updated_by\.$#'
identifier: property.notFound
count: 2
path: app/Observers/PermissionObserver.php
-
message: '#^Access to an undefined property App\\Models\\Role\:\:\$created_by\.$#'
identifier: property.notFound
count: 1
path: app/Observers/RoleObserver.php
-
message: '#^Access to an undefined property App\\Models\\Role\:\:\$updated_by\.$#'
identifier: property.notFound
count: 3
path: app/Observers/RoleObserver.php
-
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:role\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Repositories/UserRepository.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$name\.$#'
identifier: property.notFound
count: 1
path: app/Services/AI/LogAnalysisService.php
-
message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\<int,Spatie\\Activitylog\\Models\\Activity\>\:\:map\(\) contains unresolvable type\.$#'
identifier: argument.unresolvableType
count: 1
path: app/Services/AI/LogAnalysisService.php
-
message: '#^Right side of && is always true\.$#'
identifier: booleanAnd.rightAlwaysTrue
count: 1
path: app/Services/MobileConfig/MobileConfigService.php
-
message: '#^Call to an undefined method Illuminate\\Redis\\Connections\\Connection\:\:executeRaw\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Services/Monitoring/SystemMonitoringService.php
-
message: '#^Parameter \#1 \$value of function count expects array\|Countable, string given\.$#'
identifier: argument.type
count: 1
path: app/Services/Monitoring/SystemMonitoringService.php
-
message: '#^Parameter \#2 \$pattern of method Redis\:\:scan\(\) expects string\|null, array\<string, int\|string\> given\.$#'
identifier: argument.type
count: 1
path: app/Services/Monitoring/SystemMonitoringService.php
-
message: '#^Parameter \#3 \$callback of static method Illuminate\\Cache\\Repository\:\:remember\(\) contains unresolvable type\.$#'
identifier: argument.unresolvableType
count: 1
path: app/Services/Monitoring/SystemMonitoringService.php
-
message: '#^Offset ''description'' on array\{type\: ''bool''\|''float''\|''image_path''\|''int''\|''string''\|''text'', group\: ''ai_config''\|''backups''\|''branding''\|''content_legal''\|''feature_flags''\|''ip_access''\|''login_security''\|''maintenance''\|''monitoring''\|''notifications''\|''password_policy''\|''regional''\|''sap_integration''\|''session_security'', is_public\: bool, default\: 0\|0\.7\|1\|5\|7\|8\|15\|30\|60\|64\|100\|120\|587\|2000\|3600\|''''\|''\*''\|''/auth/callback''\|''0''\|''00''\|''02\:00''\|''100''\|''Asia/Jakarta''\|''d/m/Y''\|''daily''\|''email''\|''en''\|''failed''\|''gpt''\|''gpt\-4o''\|''H\:i''\|''http\://localhost…''\|''Laravel''\|''LaravelBackups''\|''local''\|''noreply@example\.com''\|''Production''\|''redis''\|''Scheduled System…''\|''smtp''\|''System Notification''\|''The system is…''\|''tls''\|''us\-east\-1''\|''v2''\|bool\|null, description\: ''2FA Method''\|''About Us Content''\|''Active AI provider''\|''Admin IP Whitelist …''\|''AES\-256 Encryption''\|''AI max tokens''\|''AI temperature''\|''Allow Remember Me…''\|''Allowed CORS Headers''\|''Allowed CORS Methods''\|''Allowed CORS Origins''\|''Anthropic Claude…''\|''Application favicon…''\|''Application logo…''\|''Application name''\|''Auto Logout Idle …''\|''Automatically block…''\|''Backup Frequency''\|''Bypass secret key''\|''Compress with gzip''\|''Concurrent Session…''\|''Contact email for…''\|''Current version of…''\|''Date display format''\|''DeepSeek API Key''\|''Default AI model''\|''Default language''\|''Default system…''\|''Default Telegram…''\|''Description text''\|''Email Driver \(smtp,…''\|''Enable 2FA''\|''Enable AI services''\|''Enable API…''\|''Enable automated…''\|''Enable Captcha''\|''Enable Cookie…''\|''Enable HTTP Strict…''\|''Enable Laravel…''\|''Enable maintenance…''\|''Enable Passkeys …''\|''Enable/Disable the…''\|''Encrypt Session Data''\|''Encryption \(tls/ssl…''\|''Encryption Key \(min…''\|''Environment…''\|''Estimated end time''\|''Excluded Tables …''\|''Execution Time''\|''Facebook App ID''\|''Facebook App Secret''\|''Footer text''\|''Force HTTPS…''\|''GitHub Client ID''\|''GitHub Client Secret''\|''Global IP Blacklist…''\|''Global Social Login…''\|''Google Client ID''\|''Google Client Secret''\|''Google Drive Client…''\|''Google Drive Folder…''\|''Google Drive…''\|''Google Gemini API…''\|''Help Center / FAQ…''\|''Hits threshold…''\|''Lockout Duration …''\|''Log Login Activity''\|''Maintenance…''\|''Maintenance message''\|''Maintenance page…''\|''Make PDP agreement…''\|''Max requests per…''\|''Maximum Login…''\|''Maximum Password…''\|''Minimum Password…''\|''Mistral AI API Key''\|''Notification Target…''\|''Notification…''\|''Notify upon Lockout''\|''Official company…''\|''Ollama Base URL''\|''Only allow 1 active…''\|''OpenAI GPT API Key''\|''OpenRouter API Key''\|''Password Reset Link…''\|''Password Validity …''\|''Prevent Password…''\|''Primary tagline''\|''Privacy Policy \(UU…''\|''reCAPTCHA Secret''\|''reCAPTCHA Site Key''\|''reCAPTCHA Version''\|''Remember Me…''\|''Remember Trusted…''\|''Require Lowercase…''\|''Require Numbers''\|''Require Symbols /…''\|''Require Uppercase…''\|''Retention Policy …''\|''Retry\-After seconds''\|''S3 Access Key''\|''S3 Bucket Name''\|''S3 Custom Endpoint …''\|''S3 Region''\|''S3 Secret Key''\|''SAP Application…''\|''SAP Client Number''\|''SAP Password''\|''SAP RFC Trace Level''\|''SAP Router string …''\|''SAP System Number''\|''SAP Username''\|''Secondary tagline''\|''Secure Cookie …''\|''Security Policy…''\|''Sender Display Name''\|''Sender Email Address''\|''Session Driver …''\|''Session Lifetime /…''\|''SMTP Host Server''\|''SMTP Password''\|''SMTP Port \(587/465…''\|''SMTP Username''\|''Storage Driver …''\|''System default…''\|''Telegram Bot Token''\|''Terms of Use Content''\|''Time display format…''\|''Toggle Facebook…''\|''Toggle GitHub OAuth…''\|''Toggle Google OAuth…''\|''Toggle notification…''\|''Whitelisted IP…''\|''xAI Grok API Key''\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: app/Services/System/GlobalSearchService.php
-
message: '#^Offset ''group'' on array\{type\: ''bool''\|''float''\|''image_path''\|''int''\|''string''\|''text'', group\: ''ai_config''\|''backups''\|''branding''\|''content_legal''\|''feature_flags''\|''ip_access''\|''login_security''\|''maintenance''\|''monitoring''\|''notifications''\|''password_policy''\|''regional''\|''sap_integration''\|''session_security'', is_public\: bool, default\: 0\|0\.7\|1\|5\|7\|8\|15\|30\|60\|64\|100\|120\|587\|2000\|3600\|''''\|''\*''\|''/auth/callback''\|''0''\|''00''\|''02\:00''\|''100''\|''Asia/Jakarta''\|''d/m/Y''\|''daily''\|''email''\|''en''\|''failed''\|''gpt''\|''gpt\-4o''\|''H\:i''\|''http\://localhost…''\|''Laravel''\|''LaravelBackups''\|''local''\|''noreply@example\.com''\|''Production''\|''redis''\|''Scheduled System…''\|''smtp''\|''System Notification''\|''The system is…''\|''tls''\|''us\-east\-1''\|''v2''\|bool\|null, description\: ''2FA Method''\|''About Us Content''\|''Active AI provider''\|''Admin IP Whitelist …''\|''AES\-256 Encryption''\|''AI max tokens''\|''AI temperature''\|''Allow Remember Me…''\|''Allowed CORS Headers''\|''Allowed CORS Methods''\|''Allowed CORS Origins''\|''Anthropic Claude…''\|''Application favicon…''\|''Application logo…''\|''Application name''\|''Auto Logout Idle …''\|''Automatically block…''\|''Backup Frequency''\|''Bypass secret key''\|''Compress with gzip''\|''Concurrent Session…''\|''Contact email for…''\|''Current version of…''\|''Date display format''\|''DeepSeek API Key''\|''Default AI model''\|''Default language''\|''Default system…''\|''Default Telegram…''\|''Description text''\|''Email Driver \(smtp,…''\|''Enable 2FA''\|''Enable AI services''\|''Enable API…''\|''Enable automated…''\|''Enable Captcha''\|''Enable Cookie…''\|''Enable HTTP Strict…''\|''Enable Laravel…''\|''Enable maintenance…''\|''Enable Passkeys …''\|''Enable/Disable the…''\|''Encrypt Session Data''\|''Encryption \(tls/ssl…''\|''Encryption Key \(min…''\|''Environment…''\|''Estimated end time''\|''Excluded Tables …''\|''Execution Time''\|''Facebook App ID''\|''Facebook App Secret''\|''Footer text''\|''Force HTTPS…''\|''GitHub Client ID''\|''GitHub Client Secret''\|''Global IP Blacklist…''\|''Global Social Login…''\|''Google Client ID''\|''Google Client Secret''\|''Google Drive Client…''\|''Google Drive Folder…''\|''Google Drive…''\|''Google Gemini API…''\|''Help Center / FAQ…''\|''Hits threshold…''\|''Lockout Duration …''\|''Log Login Activity''\|''Maintenance…''\|''Maintenance message''\|''Maintenance page…''\|''Make PDP agreement…''\|''Max requests per…''\|''Maximum Login…''\|''Maximum Password…''\|''Minimum Password…''\|''Mistral AI API Key''\|''Notification Target…''\|''Notification…''\|''Notify upon Lockout''\|''Official company…''\|''Ollama Base URL''\|''Only allow 1 active…''\|''OpenAI GPT API Key''\|''OpenRouter API Key''\|''Password Reset Link…''\|''Password Validity …''\|''Prevent Password…''\|''Primary tagline''\|''Privacy Policy \(UU…''\|''reCAPTCHA Secret''\|''reCAPTCHA Site Key''\|''reCAPTCHA Version''\|''Remember Me…''\|''Remember Trusted…''\|''Require Lowercase…''\|''Require Numbers''\|''Require Symbols /…''\|''Require Uppercase…''\|''Retention Policy …''\|''Retry\-After seconds''\|''S3 Access Key''\|''S3 Bucket Name''\|''S3 Custom Endpoint …''\|''S3 Region''\|''S3 Secret Key''\|''SAP Application…''\|''SAP Client Number''\|''SAP Password''\|''SAP RFC Trace Level''\|''SAP Router string …''\|''SAP System Number''\|''SAP Username''\|''Secondary tagline''\|''Secure Cookie …''\|''Security Policy…''\|''Sender Display Name''\|''Sender Email Address''\|''Session Driver …''\|''Session Lifetime /…''\|''SMTP Host Server''\|''SMTP Password''\|''SMTP Port \(587/465…''\|''SMTP Username''\|''Storage Driver …''\|''System default…''\|''Telegram Bot Token''\|''Terms of Use Content''\|''Time display format…''\|''Toggle Facebook…''\|''Toggle GitHub OAuth…''\|''Toggle Google OAuth…''\|''Toggle notification…''\|''Whitelisted IP…''\|''xAI Grok API Key''\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: app/Services/System/GlobalSearchService.php
-
message: '#^Unknown parameter \$user_id in call to App\\Events\\SystemNotification constructor\.$#'
identifier: argument.unknown
count: 1
path: app/Services/System/MaintenanceManagementService.php
-
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\File\\UploadedFile\:\:store\(\)\.$#'
identifier: method.notFound
count: 1
path: app/Services/SystemConfig/SettingFileUploader.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$type\.$#'
identifier: property.notFound
count: 1
path: app/Services/SystemConfig/SystemConfigService.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$value\.$#'
identifier: property.notFound
count: 1
path: app/Services/SystemConfig/SystemConfigService.php
-
message: '#^Offset ''description'' on array\{type\: ''bool''\|''float''\|''image_path''\|''int''\|''string''\|''text'', group\: ''ai_config''\|''backups''\|''branding''\|''content_legal''\|''feature_flags''\|''ip_access''\|''login_security''\|''maintenance''\|''monitoring''\|''notifications''\|''password_policy''\|''regional''\|''sap_integration''\|''session_security'', is_public\: bool, default\: 0\|0\.7\|1\|5\|7\|8\|15\|30\|60\|64\|100\|120\|587\|2000\|3600\|''''\|''\*''\|''/auth/callback''\|''0''\|''00''\|''02\:00''\|''100''\|''Asia/Jakarta''\|''d/m/Y''\|''daily''\|''email''\|''en''\|''failed''\|''gpt''\|''gpt\-4o''\|''H\:i''\|''http\://localhost…''\|''Laravel''\|''LaravelBackups''\|''local''\|''noreply@example\.com''\|''Production''\|''redis''\|''Scheduled System…''\|''smtp''\|''System Notification''\|''The system is…''\|''tls''\|''us\-east\-1''\|''v2''\|bool\|null, description\: ''2FA Method''\|''About Us Content''\|''Active AI provider''\|''Admin IP Whitelist …''\|''AES\-256 Encryption''\|''AI max tokens''\|''AI temperature''\|''Allow Remember Me…''\|''Allowed CORS Headers''\|''Allowed CORS Methods''\|''Allowed CORS Origins''\|''Anthropic Claude…''\|''Application favicon…''\|''Application logo…''\|''Application name''\|''Auto Logout Idle …''\|''Automatically block…''\|''Backup Frequency''\|''Bypass secret key''\|''Compress with gzip''\|''Concurrent Session…''\|''Contact email for…''\|''Current version of…''\|''Date display format''\|''DeepSeek API Key''\|''Default AI model''\|''Default language''\|''Default system…''\|''Default Telegram…''\|''Description text''\|''Email Driver \(smtp,…''\|''Enable 2FA''\|''Enable AI services''\|''Enable API…''\|''Enable automated…''\|''Enable Captcha''\|''Enable Cookie…''\|''Enable HTTP Strict…''\|''Enable Laravel…''\|''Enable maintenance…''\|''Enable Passkeys …''\|''Enable/Disable the…''\|''Encrypt Session Data''\|''Encryption \(tls/ssl…''\|''Encryption Key \(min…''\|''Environment…''\|''Estimated end time''\|''Excluded Tables …''\|''Execution Time''\|''Facebook App ID''\|''Facebook App Secret''\|''Footer text''\|''Force HTTPS…''\|''GitHub Client ID''\|''GitHub Client Secret''\|''Global IP Blacklist…''\|''Global Social Login…''\|''Google Client ID''\|''Google Client Secret''\|''Google Drive Client…''\|''Google Drive Folder…''\|''Google Drive…''\|''Google Gemini API…''\|''Help Center / FAQ…''\|''Hits threshold…''\|''Lockout Duration …''\|''Log Login Activity''\|''Maintenance…''\|''Maintenance message''\|''Maintenance page…''\|''Make PDP agreement…''\|''Max requests per…''\|''Maximum Login…''\|''Maximum Password…''\|''Minimum Password…''\|''Mistral AI API Key''\|''Notification Target…''\|''Notification…''\|''Notify upon Lockout''\|''Official company…''\|''Ollama Base URL''\|''Only allow 1 active…''\|''OpenAI GPT API Key''\|''OpenRouter API Key''\|''Password Reset Link…''\|''Password Validity …''\|''Prevent Password…''\|''Primary tagline''\|''Privacy Policy \(UU…''\|''reCAPTCHA Secret''\|''reCAPTCHA Site Key''\|''reCAPTCHA Version''\|''Remember Me…''\|''Remember Trusted…''\|''Require Lowercase…''\|''Require Numbers''\|''Require Symbols /…''\|''Require Uppercase…''\|''Retention Policy …''\|''Retry\-After seconds''\|''S3 Access Key''\|''S3 Bucket Name''\|''S3 Custom Endpoint …''\|''S3 Region''\|''S3 Secret Key''\|''SAP Application…''\|''SAP Client Number''\|''SAP Password''\|''SAP RFC Trace Level''\|''SAP Router string …''\|''SAP System Number''\|''SAP Username''\|''Secondary tagline''\|''Secure Cookie …''\|''Security Policy…''\|''Sender Display Name''\|''Sender Email Address''\|''Session Driver …''\|''Session Lifetime /…''\|''SMTP Host Server''\|''SMTP Password''\|''SMTP Port \(587/465…''\|''SMTP Username''\|''Storage Driver …''\|''System default…''\|''Telegram Bot Token''\|''Terms of Use Content''\|''Time display format…''\|''Toggle Facebook…''\|''Toggle GitHub OAuth…''\|''Toggle Google OAuth…''\|''Toggle notification…''\|''Whitelisted IP…''\|''xAI Grok API Key''\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: app/Services/SystemConfig/SystemConfigService.php
-
message: '#^Offset ''is_public'' on array\{type\: ''bool''\|''float''\|''image_path''\|''int''\|''string''\|''text'', group\: ''ai_config''\|''backups''\|''branding''\|''content_legal''\|''feature_flags''\|''ip_access''\|''login_security''\|''maintenance''\|''monitoring''\|''notifications''\|''password_policy''\|''regional''\|''sap_integration''\|''session_security'', is_public\: bool, default\: 0\|0\.7\|1\|5\|7\|8\|15\|30\|60\|64\|100\|120\|587\|2000\|3600\|''''\|''\*''\|''/auth/callback''\|''0''\|''00''\|''02\:00''\|''100''\|''Asia/Jakarta''\|''d/m/Y''\|''daily''\|''email''\|''en''\|''failed''\|''gpt''\|''gpt\-4o''\|''H\:i''\|''http\://localhost…''\|''Laravel''\|''LaravelBackups''\|''local''\|''noreply@example\.com''\|''Production''\|''redis''\|''Scheduled System…''\|''smtp''\|''System Notification''\|''The system is…''\|''tls''\|''us\-east\-1''\|''v2''\|bool\|null, description\: ''2FA Method''\|''About Us Content''\|''Active AI provider''\|''Admin IP Whitelist …''\|''AES\-256 Encryption''\|''AI max tokens''\|''AI temperature''\|''Allow Remember Me…''\|''Allowed CORS Headers''\|''Allowed CORS Methods''\|''Allowed CORS Origins''\|''Anthropic Claude…''\|''Application favicon…''\|''Application logo…''\|''Application name''\|''Auto Logout Idle …''\|''Automatically block…''\|''Backup Frequency''\|''Bypass secret key''\|''Compress with gzip''\|''Concurrent Session…''\|''Contact email for…''\|''Current version of…''\|''Date display format''\|''DeepSeek API Key''\|''Default AI model''\|''Default language''\|''Default system…''\|''Default Telegram…''\|''Description text''\|''Email Driver \(smtp,…''\|''Enable 2FA''\|''Enable AI services''\|''Enable API…''\|''Enable automated…''\|''Enable Captcha''\|''Enable Cookie…''\|''Enable HTTP Strict…''\|''Enable Laravel…''\|''Enable maintenance…''\|''Enable Passkeys …''\|''Enable/Disable the…''\|''Encrypt Session Data''\|''Encryption \(tls/ssl…''\|''Encryption Key \(min…''\|''Environment…''\|''Estimated end time''\|''Excluded Tables …''\|''Execution Time''\|''Facebook App ID''\|''Facebook App Secret''\|''Footer text''\|''Force HTTPS…''\|''GitHub Client ID''\|''GitHub Client Secret''\|''Global IP Blacklist…''\|''Global Social Login…''\|''Google Client ID''\|''Google Client Secret''\|''Google Drive Client…''\|''Google Drive Folder…''\|''Google Drive…''\|''Google Gemini API…''\|''Help Center / FAQ…''\|''Hits threshold…''\|''Lockout Duration …''\|''Log Login Activity''\|''Maintenance…''\|''Maintenance message''\|''Maintenance page…''\|''Make PDP agreement…''\|''Max requests per…''\|''Maximum Login…''\|''Maximum Password…''\|''Minimum Password…''\|''Mistral AI API Key''\|''Notification Target…''\|''Notification…''\|''Notify upon Lockout''\|''Official company…''\|''Ollama Base URL''\|''Only allow 1 active…''\|''OpenAI GPT API Key''\|''OpenRouter API Key''\|''Password Reset Link…''\|''Password Validity …''\|''Prevent Password…''\|''Primary tagline''\|''Privacy Policy \(UU…''\|''reCAPTCHA Secret''\|''reCAPTCHA Site Key''\|''reCAPTCHA Version''\|''Remember Me…''\|''Remember Trusted…''\|''Require Lowercase…''\|''Require Numbers''\|''Require Symbols /…''\|''Require Uppercase…''\|''Retention Policy …''\|''Retry\-After seconds''\|''S3 Access Key''\|''S3 Bucket Name''\|''S3 Custom Endpoint …''\|''S3 Region''\|''S3 Secret Key''\|''SAP Application…''\|''SAP Client Number''\|''SAP Password''\|''SAP RFC Trace Level''\|''SAP Router string …''\|''SAP System Number''\|''SAP Username''\|''Secondary tagline''\|''Secure Cookie …''\|''Security Policy…''\|''Sender Display Name''\|''Sender Email Address''\|''Session Driver …''\|''Session Lifetime /…''\|''SMTP Host Server''\|''SMTP Password''\|''SMTP Port \(587/465…''\|''SMTP Username''\|''Storage Driver …''\|''System default…''\|''Telegram Bot Token''\|''Terms of Use Content''\|''Time display format…''\|''Toggle Facebook…''\|''Toggle GitHub OAuth…''\|''Toggle Google OAuth…''\|''Toggle notification…''\|''Whitelisted IP…''\|''xAI Grok API Key''\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 2
path: app/Services/SystemConfig/SystemConfigService.php
-
message: '#^Trait App\\Traits\\HasAutoCode is used zero times and is not analysed\.$#'
identifier: trait.unused
count: 1
path: app/Traits/HasAutoCode.php
+20
View File
@@ -0,0 +1,20 @@
includes:
- vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
level: 5
paths:
- app
excludePaths:
- app/Console/Commands
ignoreErrors:
# Spatie permission magic
- '#Call to an undefined method App\\Models\\User::permission\(\)#'
# Eloquent dynamic property/scope warnings on Spatie Model extensions
- '#Access to an undefined property Spatie\\#'
tmpDir: storage/framework/phpstan
parallel:
processTimeout: 300.0
maximumNumberOfProcesses: 4
reportUnmatchedIgnoredErrors: false
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array" force="true"/>
<env name="DB_DATABASE" value="testing"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="PULSE_ENABLED" value="false" force="true"/>
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
</php>
</phpunit>
+25
View File
@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Some files were not shown because too many files have changed in this diff Show More