Compare commits
14 Commits
b2d60e680d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 577e385764 | |||
| 03c10f91e2 | |||
| a4a620d90a | |||
| 10785f3559 | |||
| cb7a70a289 | |||
| 0c65a7811b | |||
| 76d7a5c5c6 | |||
| 3baf631b0b | |||
| 5075baa032 | |||
| 4732492d15 | |||
| 13bfb5f32f | |||
| c415b87fa6 | |||
| 2311767e9f | |||
| c72dde4484 |
@@ -34,3 +34,7 @@ yarn-error.log*
|
|||||||
!/storage/framework/testing/.gitignore
|
!/storage/framework/testing/.gitignore
|
||||||
!/storage/framework/phpstan/.gitignore
|
!/storage/framework/phpstan/.gitignore
|
||||||
!/storage/logs/.gitignore
|
!/storage/logs/.gitignore
|
||||||
|
|
||||||
|
# Subproject ignores
|
||||||
|
/Project/storage/
|
||||||
|
/Project/**/*.log
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,223 +1,233 @@
|
|||||||
# biiproject
|
# ⚡ biiproject-kit v1
|
||||||
|
|
||||||
Aplikasi web manajemen bisnis berbasis **Laravel 13** dengan PostgreSQL, Redis, dan WebSocket real-time.
|
[](https://laravel.com)
|
||||||
|
[](https://www.postgresql.org)
|
||||||
|
[](https://redis.io)
|
||||||
|
[]()
|
||||||
|
[]()
|
||||||
|
[]()
|
||||||
|
|
||||||
[]() []() []() []()
|
A high-performance, secure, and enterprise-ready **Laravel 13** starter kit featuring a comprehensive real-time admin monitoring dashboard, a granular Spatie permission matrix with Blade templates, custom backup services, and ready-to-use Expo React Native mobile application API integration. **Version 1** is designed to provide a highly optimized and rock-solid foundation for business management and SaaS systems.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fitur Utama
|
## 🚀 Key Architectural Features in v1
|
||||||
|
|
||||||
- **Dashboard Admin Real-time** — ringkasan CPU/RAM/Disk/Live Users/Queue dengan update via WebSocket (Reverb). Widget bisa disembunyikan, diurutkan ulang (drag), dan disimpan per-user. Fallback ke polling 30 detik jika Reverb tidak terhubung.
|
* 📊 **Real-time Admin Monitoring** — Dynamic telemetry panel tracking CPU, RAM, Disk usage, and live active users powered by Laravel Reverb WebSockets. Configurable drag-and-drop widget layout is saved per user.
|
||||||
- **Custom Dashboard Widgets** — 7 widget bawaan (cpu, ram, disk, live users, queues, activity feed, AI insight). Per-user layout tersimpan di `dashboard_widget_preferences`. Toggle show/hide + drag-to-reorder via SortableJS.
|
* 🛡️ **Granular Tab-Level Access** — Highly custom authorization gates mapping 85 permission levels for Global Settings and Mobile Remote variables using Blade directives (`@cantab` and `@managetab`).
|
||||||
- **Manajemen Pengguna** — role & permission granular (Spatie), soft delete + restore + force delete, bulk action
|
* ⚙️ **Integrated Control Console** — Unified administration backend governing application branding details, live SMTP servers, OAuth login triggers, automated backups, and maintenance gates.
|
||||||
- **Global Settings** — branding, keamanan, email, AI, SAP, backup, dan lainnya dalam satu panel
|
* 💾 **Secure Backup Automation** — Integrated scheduling mechanisms routing encrypted backups to Cloud storage (Amazon S3 or Google Drive) with custom integrity verification.
|
||||||
- **Mobile Settings** — kontrol remote konfigurasi aplikasi Android/iOS
|
* 🤖 **AI Intelligence Engine** — Direct adapters for OpenAI, Gemini, and Mistral, providing automatic Swagger annotations, system diagnostic logs auditing, and real-time security score assessments.
|
||||||
- **Maintenance Mode** — offline page dengan countdown, bypass key, dan IP whitelist
|
* 📱 **Expo Mobile Application integration** — Native Sanctum API token exchange, dynamic configuration sync, and device token registration endpoints ready for Push Notifications.
|
||||||
- **Backup & Restore** — Local, Amazon S3, atau Google Drive dengan enkripsi opsional
|
|
||||||
- **System Monitoring** — log Laravel, log SAP, log mobile, background job, AI usage, health check
|
|
||||||
- **Notifikasi Real-time** — WebSocket via Laravel Reverb + Notification Center. Dashboard stats di-push tiap menit via `dashboard:broadcast-stats`.
|
|
||||||
- **Granular Tab Permissions** — 85 permission level tab untuk Global/Mobile Settings. `CheckTabPermission` middleware + `@cantab`/`@managetab` Blade directives. Picker role dengan UI two-panel drag-drop dan category headers.
|
|
||||||
- **Session Manager** — lihat & paksa logout sesi aktif, single-session enforcement opsional
|
|
||||||
- **Legal & Content** — Privacy Policy, ToS, About (WYSIWYG), kepatuhan UU PDP No. 27/2022
|
|
||||||
- **Mobile App** — React Native + Expo dengan API Sanctum, OTP, device token (push notification)
|
|
||||||
- **Audit Trail** — semua perubahan tercatat via Spatie ActivityLog + Action Log
|
|
||||||
- **Error Monitoring** — Sentry integration untuk production error tracking
|
|
||||||
- **Passkeys (WebAuthn)** — login biometrik/FIDO2
|
|
||||||
- **Social OAuth** — Google, Facebook, GitHub (callback aman terhadap identity-overwrite)
|
|
||||||
- **AI Intelligence Engine** — Integrasi OpenAI, Gemini, Claude, DeepSeek, Mistral, dll.
|
|
||||||
- **Smart Search (CMD+K)** — Navigasi cerdas & AI Assistant terintegrasi
|
|
||||||
- **AI Security Audit** — Skor keamanan otomatis & rekomendasi perkuatan (hardening)
|
|
||||||
- **AI Error Diagnostics** — Analisis otomatis & saran perbaikan saat terjadi error sistem
|
|
||||||
- **API Documentation** — Swagger/OpenAPI otomatis (l5-swagger) dengan bantuan AI
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Keamanan Bawaan
|
## 🛠️ Tech Stack & Dependencies
|
||||||
|
|
||||||
- **Security headers**: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `X-XSS-Protection`, dan `Strict-Transport-Security` (HTTPS) di-set otomatis oleh middleware global.
|
| Layer | Technology | Version | Description |
|
||||||
- **Rate limiting**: throttle pada `/login`, `/2fa`, `/forgot-password`, `/api/v1/otp/*`, dan endpoint mobile lain. Per-IP bucket terisolasi.
|
|---|---|---|---|
|
||||||
- **Password policy**: panjang min/max, charset wajib, expiry, dan **history reuse blocker** (Bcrypt 12 rounds).
|
| **Core Framework** | Laravel | `13.x` | Modern backend routing, scheduler, and service container |
|
||||||
- **IP access control**: whitelist admin, blacklist global, auto-block on burst (24 jam) dengan alert Telegram.
|
| **Database Engine** | PostgreSQL | `15.x` | Relational database storage |
|
||||||
- **Data integrity**: FK constraint penuh di semua tabel audit; soft-delete cascade tested.
|
| **Caching & Queue** | Redis | `Alpine` | High-speed cache memory and asynchronous queues |
|
||||||
- **Data retention otomatis**: 10 tabel/model memiliki kebijakan retensi — OTP & trusted device dipangkas saat expired, log AI & healing 90 hari, password history 365 hari, Telescope 48 jam. Dijalankan via `model:prune` + `telescope:prune` setiap dini hari.
|
| **Real-time Server**| Laravel Reverb | `1.x` | Native high-performance WebSockets broadcaster |
|
||||||
|
| **Frontend UI** | Blade + SortableJS | `v1.x` | Server-side templating with interactive drag-drop widgets |
|
||||||
|
| **Authentication** | Breeze + WebAuthn | `v2.x` | Classic web sessions + FIDO2 Biometric Passkeys |
|
||||||
|
| **Roles & Privileges** | Spatie Permissions | `v6.x` | Granular permission layers mapped to Blade templates |
|
||||||
|
| **Audit Trail** | Spatie Activity Logs| `v4.x` | Transparent logging for models and user actions |
|
||||||
|
| **Docs Generator** | Swagger (L5-Swagger) | `v8.x` | OpenAPI spec files with integrated AI assistant |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quality Gate
|
## 📂 Directory Structure Overview
|
||||||
|
|
||||||
| Check | Status | Tool |
|
This project follows strict clean code practices and Laravel standard modular architectures:
|
||||||
|-------|--------|------|
|
|
||||||
| Unit & feature tests | **371 / 371 ✓** | Pest 4 |
|
|
||||||
| Static analysis | **clean** | Larastan level 5 (baseline) |
|
|
||||||
| Code style | **clean** | Laravel Pint (PSR-12) |
|
|
||||||
| Dependency audit | **0 vulns** | `composer audit` |
|
|
||||||
| N+1 regression locks | **3 datatables** | Pest + Query Log |
|
|
||||||
|
|
||||||
CI menjalankan keempatnya di setiap push/PR — lihat [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
|
```text
|
||||||
|
├── app/
|
||||||
```bash
|
│ ├── Exceptions/ # SystemConfig/Backup/Monitoring exception classes
|
||||||
./vendor/bin/sail artisan test
|
│ ├── Helpers/ # SettingsHelper, SessionHelper, ImpersonateHelper, PasswordRuleHelper
|
||||||
./vendor/bin/sail bin phpstan analyse
|
│ ├── Http/
|
||||||
./vendor/bin/sail bin pint --test
|
│ │ ├── Controllers/ # AccessControl, Auth, SystemSettings, WebAuthn, Dashboard modules
|
||||||
./vendor/bin/sail composer audit
|
│ │ ├── Helpers/ # Standardized JSON API responses formats
|
||||||
|
│ │ └── Middleware/ # SecurityHeaders, IpAccessControl, CheckActivePermission, Gzip
|
||||||
|
│ ├── Models/ # Primary Eloquent schemas (User, OtpCode, PasswordHistory, DeviceToken)
|
||||||
|
│ └── Services/ # AI Service adapters, Backup management, SystemConfig caches
|
||||||
|
├── config/ # Consolidated application parameters
|
||||||
|
├── database/
|
||||||
|
│ ├── migrations/ # Database schemas (40+ migrations)
|
||||||
|
│ └── seeders/ # Dynamic settings, mobile variables, and primary RBAC matrix
|
||||||
|
├── docker/ # Standardized Sail multi-service docker compose environments
|
||||||
|
├── public/ # Standard assets (vendor scripts, custom CSS)
|
||||||
|
├── resources/
|
||||||
|
│ └── views/ # Server-side Blade layouts, templates, and view components
|
||||||
|
├── routes/ # Divided routing protocols (web, api, auth, ai, channels, console)
|
||||||
|
└── tests/ # 371 feature-rich Pest integration tests
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Perintah Artisan Khusus
|
## ⚡ Quick Start & Development
|
||||||
|
|
||||||
Sistem ini dilengkapi dengan perintah CLI tambahan untuk memudahkan administrasi:
|
Get your development environment up and running quickly:
|
||||||
|
|
||||||
| Perintah | Deskripsi |
|
### Manual Setup (Without Docker)
|
||||||
|----------|-----------|
|
|
||||||
| `php artisan system:check` | Audit kesehatan infrastruktur (DB, Redis, Storage, AI). |
|
1. **Clone & Install Dependencies:**
|
||||||
| `php artisan system:optimize` | Optimasi cache & pembersihan log produksi. |
|
```bash
|
||||||
| `php artisan ai:swagger {path}` | Menghasilkan anotasi Swagger otomatis menggunakan AI. |
|
git clone <repo-url> Project && cd Project
|
||||||
| `php artisan system:send-digest` | Mengirim ringkasan kesehatan sistem mingguan ke Admin. |
|
composer install
|
||||||
| `php artisan backups:verify` | Verifikasi integritas file cadangan di cloud/lokal. |
|
npm install
|
||||||
| `php artisan l5-swagger:generate` | Regenerasi dokumentasi API OpenAPI. |
|
```
|
||||||
| `php artisan model:prune` | Pangkas data kedaluwarsa (OTP, trusted device, AI log, password history, dll). |
|
2. **Setup Environment Configuration:**
|
||||||
| `php artisan telescope:prune --hours=48` | Hapus Telescope entries lebih dari 48 jam. |
|
```bash
|
||||||
| `php artisan dashboard:broadcast-stats` | Broadcast statistik sistem terbaru ke channel WebSocket `admin.monitoring`. Dijadwalkan tiap menit. |
|
cp .env.example .env
|
||||||
|
# Configure your DB_HOST=127.0.0.1 and REDIS_HOST=127.0.0.1 in .env
|
||||||
|
php artisan key:generate
|
||||||
|
```
|
||||||
|
3. **Run Migrations & Seeds:**
|
||||||
|
```bash
|
||||||
|
php artisan migrate --seed
|
||||||
|
```
|
||||||
|
4. **Launch Development Servers:**
|
||||||
|
```bash
|
||||||
|
composer run dev
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mulai Cepat (Development)
|
### 🔧 Containerized Setup (Laravel Sail) — Recommended
|
||||||
|
|
||||||
### Tanpa Docker
|
If you prefer using Docker:
|
||||||
|
|
||||||
```bash
|
1. **Spin Up Containers:**
|
||||||
# 1. Clone & install
|
```bash
|
||||||
git clone <repo-url> Project && cd Project
|
./vendor/bin/sail up -d
|
||||||
composer install
|
```
|
||||||
npm install
|
2. **Initialize Database:**
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan migrate --seed
|
||||||
|
```
|
||||||
|
|
||||||
# 2. Environment
|
The application will be accessible immediately at `http://localhost:8000`.
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env: DB_HOST=127.0.0.1, REDIS_HOST=127.0.0.1
|
|
||||||
php artisan key:generate
|
|
||||||
|
|
||||||
# 3. Database & seed
|
> [!TIP]
|
||||||
php artisan migrate --seed
|
> Always clear application cache after seeding is completed to reflect settings instantly:
|
||||||
|
|
||||||
# 4. Jalankan (server + vite + reverb + queue + scheduler)
|
|
||||||
composer run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Via Docker (Laravel Sail) — Direkomendasikan
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./vendor/bin/sail up -d
|
|
||||||
./vendor/bin/sail artisan migrate --seed
|
|
||||||
```
|
|
||||||
|
|
||||||
Aplikasi dapat diakses di `http://localhost:8000`.
|
|
||||||
|
|
||||||
> **Penting:** Jika seeder dijalankan, selalu clear cache setelahnya agar perubahan muncul di aplikasi:
|
|
||||||
> ```bash
|
> ```bash
|
||||||
> ./vendor/bin/sail artisan cache:clear
|
> ./vendor/bin/sail artisan cache:clear
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
### Menjalankan Test Suite
|
---
|
||||||
|
|
||||||
```bash
|
## 🔐 Default Credentials
|
||||||
./vendor/bin/sail artisan test # 371 tests (full)
|
|
||||||
./vendor/bin/sail artisan test --filter Auth # filter
|
Use the default credentials below to test the RBAC capabilities of the starter kit:
|
||||||
./vendor/bin/sail bin phpstan analyse # static analysis
|
|
||||||
./vendor/bin/sail bin pint --test # code style check
|
| Role | Email | Password | Role Description |
|
||||||
./vendor/bin/sail bin pint # code style auto-fix
|
|---|---|---|---|
|
||||||
```
|
| **Super Admin** | `superadmin@biiproject.com` | `password` | Unrestricted access. Bypasses all system gates. |
|
||||||
|
| **Admin** | `admin@biiproject.com` | `password` | Manager privileges for access control, logs, and settings. |
|
||||||
|
| **User** | `user@biiproject.com` | `password` | Standard user role with read-only dashboard layout. |
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Please change default passwords immediately after deployment. Bcrypt 12 rounds + history blockers are active by default.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Akun Default (setelah seed)
|
## 🛡️ Built-in Security Policies
|
||||||
|
|
||||||
| Role | Email | Password |
|
* **Security Headers** — Automatically injected custom headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `X-XSS-Protection`, `Strict-Transport-Security`) protecting all routing responses.
|
||||||
|------|-------|----------|
|
* **Smart Rate Limiting** — Intelligent throttle thresholds applied on `/login`, `/2fa`, `/forgot-password`, `/api/v1/otp/*`, and Expo client login gates.
|
||||||
| Super Admin | superadmin@biiproject.com | password |
|
* **Robust Password Policy** — Dynamic complexity regulations (minimum length, mixed-case, numbers, special characters) with Bcrypt 12 rounds encryption and **365-day history reuse blocker**.
|
||||||
| Admin | admin@biiproject.com | password |
|
* **IP Access Control** — Customizable administrator Whitelists, global blacklists, and automated burst-block (24 hours) trigger alerting via Telegram.
|
||||||
| User | user@biiproject.com | password |
|
* **Auto Data Retention** — Dynamic automated pruning pipelines running daily via `model:prune` (expired OTPs/trusted devices, 90-day AI history logs, 48-hour Telescope database entries).
|
||||||
|
|
||||||
> Ganti password segera setelah deploy. Bcrypt 12 rounds + history block aktif by default.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dokumentasi
|
## ⚡ Quality Gate Standards
|
||||||
|
|
||||||
| Dokumen | Untuk Siapa | Isi |
|
All components are rigorously audited under continuous quality benchmarks:
|
||||||
|---------|-------------|-----|
|
|
||||||
| [README.md](README.md) | Semua | Ringkasan & quick start (file ini) |
|
| Benchmark | Standard | Auditing Tool |
|
||||||
| [USER_GUIDE.md](USER_GUIDE.md) | Admin / Operator | Cara pakai panel admin |
|
|---|---|---|
|
||||||
| [TECH_STACK.md](TECH_STACK.md) | Developer | Framework, library, plugin, tooling, CI |
|
| **Unit & Feature Tests** | `371 / 371 Passed` | Pest 4 / PHPUnit |
|
||||||
| [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) | DevOps | Instalasi server produksi |
|
| **Static Code Analysis** | `Clean` | Larastan (Level 5 Baseline) |
|
||||||
| [SECURITY.md](SECURITY.md) | All | Reporting & supply-chain advisory |
|
| **Code Style Conformity**| `Clean` | Laravel Pint (PSR-12 ruleset) |
|
||||||
| [CHANGELOG.md](CHANGELOG.md) | All | Log perubahan |
|
| **Dependency Security** | `0 Vulnerabilities` | `composer audit` |
|
||||||
| [mobile/README.md](mobile/README.md) | Mobile Dev | Build & pengembangan aplikasi Android/iOS |
|
| **Query Performance** | `0 N+1 Regressions` | Pest + Custom Query Logger |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Struktur Direktori
|
## 🔌 API Endpoints Reference (v1)
|
||||||
|
|
||||||
```
|
All endpoints are versioned and situated under `/api/v1/*`. Requests requesting authorization require an HTTP header formatted as `Authorization: Bearer <your_token>`.
|
||||||
Project/
|
|
||||||
├── app/
|
### Authentication & Config
|
||||||
│ ├── Exceptions/ SystemConfig/Backup/Monitoring exception classes
|
| Method | Endpoint | Auth | Description |
|
||||||
│ ├── Helpers/ SettingsHelper, SessionHelper, ImpersonateHelper, PasswordRuleHelper
|
|---|---|---|---|
|
||||||
│ ├── Http/
|
| `POST` | `/api/v1/login` | — | Exchange credentials for Bearer Token (Rate limited) |
|
||||||
│ │ ├── Controllers/
|
| `POST` | `/api/v1/register` | — | Register a new user account (Rate limited) |
|
||||||
│ │ │ ├── AccessControl/ User, Role, Permission, ActionLog management
|
| `POST` | `/api/v1/forgot-password`| — | Request reset password link |
|
||||||
│ │ │ ├── Admin/ Mobile settings
|
| `GET` | `/api/v1/app-config` | — | Retrieve mobile app remote configuration parameters |
|
||||||
│ │ │ ├── Api/ Sanctum-protected mobile API (v1) + Health
|
| `GET` | `/api/v1/mobile/sync` | — | Sync latest configurations and updates |
|
||||||
│ │ │ ├── Auth/ Login, 2FA, Passkey (WebAuthn), Social OAuth
|
| `POST` | `/api/v1/mobile/log` | — | Send mobile application logs to server (Rate limited) |
|
||||||
│ │ │ ├── SystemSettings/ Global settings, monitoring, backup, maintenance
|
|
||||||
│ │ │ ├── WebAuthn/ Laragear WebAuthn login/register controllers
|
### OTP Gateway
|
||||||
│ │ │ ├── DashboardController.php
|
| Method | Endpoint | Auth | Description |
|
||||||
│ │ │ ├── ImpersonateController.php
|
|---|---|---|---|
|
||||||
│ │ │ ├── LegalController.php
|
| `POST` | `/api/v1/otp/send` | — | Request verification OTP code via Email/WhatsApp (Rate limited) |
|
||||||
│ │ │ └── ProfileController.php
|
| `POST` | `/api/v1/otp/verify` | — | Validate the OTP code |
|
||||||
│ │ ├── Helpers/ ApiResponse
|
|
||||||
│ │ └── Middleware/ SecurityHeaders, IpAccessControl, CheckActivePermission,
|
### Profile & Dashboard (Authenticated)
|
||||||
│ │ CheckLegalAgreement, PasswordExpiry, GzipCompression
|
| Method | Endpoint | Auth | Description |
|
||||||
│ ├── Services/
|
|---|---|---|---|
|
||||||
│ │ ├── Auth/ PasswordPolicyService
|
| `GET` | `/api/v1/user` | Bearer | Fetch authenticated user data, roles, and permissions |
|
||||||
│ │ ├── AI/ Multi-provider AI service abstraction
|
| `POST` | `/api/v1/logout` | Bearer | Revoke current authenticated session token |
|
||||||
│ │ ├── MobileConfig/ MobileConfigService (admin → mobile sync)
|
| `POST` | `/api/v1/profile/update` | Bearer | Update user profile personal details |
|
||||||
│ │ ├── Monitoring/ SystemMonitoringService + MonitoringFormatter
|
| `POST` | `/api/v1/profile/avatar` | Bearer | Upload and update profile photo |
|
||||||
│ │ ├── Notification/ FCM, Telegram adapters
|
| `POST` | `/api/v1/profile/password` | Bearer | Change account login password |
|
||||||
│ │ ├── System/ BackupManagementService, MaintenanceManagementService,
|
| `DELETE` | `/api/v1/profile/delete` | Bearer | Self account termination/deletion |
|
||||||
│ │ │ ActivityFormatter, GlobalSearchService
|
| `GET` | `/api/v1/dashboard` | Bearer | Retrieve secure mobile dashboard analytics |
|
||||||
│ │ └── SystemConfig/ SystemConfigService + SettingDefinitions +
|
|
||||||
│ │ SettingValueCaster + SettingFileUploader
|
### Push Notification Registry
|
||||||
│ └── Models/ User, Role, Permission, SystemSetting (+ Revision),
|
| Method | Endpoint | Auth | Description |
|
||||||
│ MobileSetting, OtpCode, PasswordHistory, DeviceToken,
|
|---|---|---|---|
|
||||||
│ DashboardWidgetPreference, ...
|
| `POST` | `/api/v1/devices/register` | Bearer | Register target FCM device token |
|
||||||
├── config/ Konfigurasi Laravel
|
| `DELETE`| `/api/v1/devices/unregister`| Bearer | Revoke and unregister FCM device token |
|
||||||
├── database/
|
|
||||||
│ ├── migrations/ Skema database (40+ tabel)
|
|
||||||
│ └── seeders/ RoleAndPermission, SystemSetting, MobileSetting, AdminUser
|
|
||||||
├── docker/ Konfigurasi Sail (PHP, Postgres, Redis)
|
|
||||||
├── mobile/ Aplikasi React Native (Expo SDK 54+)
|
|
||||||
├── resources/views/ Template Blade
|
|
||||||
├── routes/
|
|
||||||
│ ├── web.php Rute web (admin panel)
|
|
||||||
│ ├── api.php Rute API mobile (prefix /api/v1)
|
|
||||||
│ ├── auth.php Rute autentikasi Breeze + 2FA + WebAuthn
|
|
||||||
│ ├── ai.php Endpoint AI assistant
|
|
||||||
│ ├── channels.php Broadcast channel auth
|
|
||||||
│ └── console.php Schedule kernel
|
|
||||||
├── storage/api-docs/ Generated OpenAPI/Swagger spec
|
|
||||||
├── storage/logs/ File log aplikasi
|
|
||||||
├── tests/
|
|
||||||
│ ├── Feature/ HTTP + integration tests
|
|
||||||
│ └── Unit/ Pure logic (Formatter, Caster, Helpers, Exceptions)
|
|
||||||
├── phpstan.neon Larastan config (level 5)
|
|
||||||
├── phpstan-baseline.neon Pre-existing errors silenced
|
|
||||||
└── .github/workflows/ci.yml Test + Lint + Static Analysis pipeline
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lisensi
|
## 🛠️ Specialized Artisan Commands
|
||||||
|
|
||||||
Proprietary © 2026 Andika Debi Putra. Lihat header tiap file. Dirancang dengan kepatuhan terhadap **UU PDP No. 27/2022**.
|
The administration console provides customized CLI commands for operational workflows:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `php artisan system:check` | Audit core infrastructure health (Database, Redis, Cloud Storage, AI engines). |
|
||||||
|
| `php artisan system:optimize` | Consolidate caches and wipe out production application logs. |
|
||||||
|
| `php artisan ai:swagger {path}` | Generate automated Swagger controller annotations utilizing OpenAI. |
|
||||||
|
| `php artisan system:send-digest` | Dispatch weekly operational system health digest to Administrators. |
|
||||||
|
| `php artisan backups:verify` | Audit and verify the integrity of local/cloud backup files. |
|
||||||
|
| `php artisan l5-swagger:generate` | Compile and regenerate OpenAPI/Swagger specifications. |
|
||||||
|
| `php artisan model:prune` | Safely clear out expired OTP keys, passwords histories, and expired device records. |
|
||||||
|
| `php artisan telescope:prune --hours=48`| Clear out Telescope registry entries older than 48 hours. |
|
||||||
|
| `php artisan dashboard:broadcast-stats`| Broadcast updated CPU/RAM/Disk stats to the admin monitoring channel. Scheduled minutely. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Related Manuals
|
||||||
|
|
||||||
|
| Document | Target Audience | Content |
|
||||||
|
|---|---|---|
|
||||||
|
| [README.md](README.md) | All Users | Quick Start & Architectural Overview (This file) |
|
||||||
|
| [USER_GUIDE.md](USER_GUIDE.md) | Administrators | Operational guidelines for the administrative panel |
|
||||||
|
| [TECH_STACK.md](TECH_STACK.md) | Developers | Architectural dependencies, CI pipelines, and plugins details |
|
||||||
|
| [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) | DevOps Engineers | Outlines production environment server deployments |
|
||||||
|
| [SECURITY.md](SECURITY.md) | All Users | Security policies and reporting protocols |
|
||||||
|
| [CHANGELOG.md](CHANGELOG.md) | All Users | Versioned repository changes log |
|
||||||
|
| [mobile/README.md](mobile/README.md) | Mobile Engineers | Outline and instructions for React Native/Expo builds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License & Terms
|
||||||
|
|
||||||
|
Proprietary © 2026 Andika Debi Putra (Debesocial). Designed and packaged to expedite development while aligning with modern security and architectural guidelines (Compliant with **UU PDP No. 27/2022**). All rights reserved.
|
||||||
|
|||||||
@@ -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 |