Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb7a70a289 | |||
| 0c65a7811b | |||
| 76d7a5c5c6 | |||
| 3baf631b0b | |||
| 5075baa032 | |||
| 4732492d15 | |||
| 13bfb5f32f | |||
| c415b87fa6 | |||
| 2311767e9f | |||
| c72dde4484 |
@@ -34,3 +34,7 @@ yarn-error.log*
|
||||
!/storage/framework/testing/.gitignore
|
||||
!/storage/framework/phpstan/.gitignore
|
||||
!/storage/logs/.gitignore
|
||||
|
||||
# Subproject ignores
|
||||
/Project/storage/
|
||||
/Project/**/*.log
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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) │
|
||||
└─────────────┘
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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}"
|
||||
@@ -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";
|
||||
@@ -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
|
||||
@@ -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*
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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'} />;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['react-native-reanimated/plugin'],
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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.' },
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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/*'],
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>>
|
||||
@@ -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!"
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 47 KiB |