chore: initialize orderPRO with docs, i18n and scss asset pipeline

This commit is contained in:
2026-02-19 01:27:51 +01:00
commit 92bbe82614
44 changed files with 2722 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
APP_NAME=orderPRO
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000
SESSION_NAME=orderpro_session
ADMIN_EMAIL=admin@orderpro.local
ADMIN_PASSWORD_HASH=$2y$10$1eRQmrhEUWgKRZpG08dKOenG4eZrvLQnLdCUfKHrZ/5dzLvxpmRYC

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
vendor/
storage/logs/
storage/sessions/
storage/cache/
storage/tmp/

10
.htaccess Normal file
View File

@@ -0,0 +1,10 @@
Options -Indexes
RewriteEngine On
# Keep ACME challenges reachable for SSL certificates.
RewriteRule ^\.well-known/ - [L]
# If hosting points to project root, internally route all requests to /public.
RewriteCond %{REQUEST_URI} !^/public/
RewriteRule ^(.*)$ public/$1 [L]

17
.vscode/ftp-kr.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"host": "host700513.hostido.net.pl",
"username": "www@orderpro.projectpro.pl",
"password": "TcVVuQD2ppdGQWnwZv6j",
"remotePath": "/public_html",
"protocol": "ftp",
"port": 21,
"fileNameEncoding": "utf8",
"autoUpload": true,
"autoDelete": false,
"autoDownload": false,
"ignoreRemoteModification": true,
"ignore": [
".git",
"/.vscode"
]
}

291
.vscode/ftp-kr.sync.cache.json vendored Normal file
View File

@@ -0,0 +1,291 @@
{
"ftp://host700513.hostido.net.pl:21@www@orderpro.projectpro.pl": {
"public_html": {
"bin": {},
"bootstrap": {
"app.php": {
"type": "-",
"size": 869,
"lmtime": 1771459558621,
"modified": false
}
},
"composer.json": {
"type": "-",
"size": 330,
"lmtime": 1771459542043,
"modified": false
},
"config": {
"app.php": {
"type": "-",
"size": 519,
"lmtime": 1771459563409,
"modified": false
},
"auth.php": {
"type": "-",
"size": 289,
"lmtime": 1771459566674,
"modified": false
}
},
"database": {
"migrations": {},
"seeders": {}
},
"DOCS": {
"BACKLOG_MIKROZADANIA.md": {
"type": "-",
"size": 3824,
"lmtime": 1771459773090,
"modified": false
},
"PLAN_PROJEKTU.md": {
"type": "-",
"size": 4607,
"lmtime": 1771459255987,
"modified": false
}
},
".env.example": {
"type": "-",
"size": 222,
"lmtime": 1771459546785,
"modified": false
},
"index.php": {
"type": "-",
"size": 71,
"lmtime": 1771459937874,
"modified": false
},
"public": {
"assets": {
"css": {
"login.css": {
"type": "-",
"size": 3811,
"lmtime": 1771459717220,
"modified": false
}
},
"img": {},
"js": {}
},
"index.php": {
"type": "-",
"size": 157,
"lmtime": 1771459549255,
"modified": false
},
"login.php": {
"type": "-",
"size": 2002,
"lmtime": 1771459339520,
"modified": false
},
".htaccess": {
"type": "-",
"size": 146,
"lmtime": 1771459552460,
"modified": false
}
},
"resources": {
"views": {
"auth": {
"login.php": {
"type": "-",
"size": 1349,
"lmtime": 1771459707811,
"modified": false
}
},
"dashboard": {
"index.php": {
"type": "-",
"size": 280,
"lmtime": 1771459711102,
"modified": false
}
},
"layouts": {
"app.php": {
"type": "-",
"size": 2151,
"lmtime": 1771459836968,
"modified": false
},
"auth.php": {
"type": "-",
"size": 712,
"lmtime": 1771459686411,
"modified": false
}
}
}
},
"routes": {
"web.php": {
"type": "-",
"size": 1491,
"lmtime": 1771459832787,
"modified": false
}
},
"src": {
"Core": {
"Http": {
"Request.php": {
"type": "-",
"size": 1311,
"lmtime": 1771459591649,
"modified": false
},
"Response.php": {
"type": "-",
"size": 1240,
"lmtime": 1771459599904,
"modified": false
}
},
"Routing": {
"Router.php": {
"type": "-",
"size": 3486,
"lmtime": 1771459614410,
"modified": false
}
},
"Security": {
"Csrf.php": {
"type": "-",
"size": 720,
"lmtime": 1771459626345,
"modified": false
}
},
"Support": {
"Env.php": {
"type": "-",
"size": 1502,
"lmtime": 1771459634233,
"modified": false
},
"Flash.php": {
"type": "-",
"size": 948,
"lmtime": 1771459639752,
"modified": false
},
"Logger.php": {
"type": "-",
"size": 1017,
"lmtime": 1771459645883,
"modified": false
},
"Session.php": {
"type": "-",
"size": 648,
"lmtime": 1771459650337,
"modified": false
}
},
"View": {
"Template.php": {
"type": "-",
"size": 1448,
"lmtime": 1771459621837,
"modified": false
}
},
"Application.php": {
"type": "-",
"size": 4232,
"lmtime": 1771459584783,
"modified": false
}
},
"Modules": {
"Auth": {
"AuthController.php": {
"type": "-",
"size": 2324,
"lmtime": 1771459827414,
"modified": false
},
"AuthMiddleware.php": {
"type": "-",
"size": 665,
"lmtime": 1771459672950,
"modified": false
},
"AuthService.php": {
"type": "-",
"size": 1584,
"lmtime": 1771459658223,
"modified": false
}
}
}
},
"storage": {
"cache": {},
"logs": {},
"sessions": {
"sess_3hu8an8qjm269lrbgrdj8hg4qf": {
"type": "-",
"size": 0,
"lmtime": 1771459760446,
"modified": false
},
"sess_3ja8jv4ed6toa9qr0akm6dopou": {
"type": "-",
"size": 84,
"lmtime": 1771459815608,
"modified": false
},
"sess_76ae54rsc2nm1kea4shv8d7rnn": {
"type": "-",
"size": 188,
"lmtime": 1771459815683,
"modified": false
},
"sess_mmv4rrajp3hbl7levd5bt5jrrg": {
"type": "-",
"size": 188,
"lmtime": 1771459767545,
"modified": false
},
"sess_qlos51ql56hego9hj8m27hota1": {
"type": "-",
"size": 84,
"lmtime": 1771459760450,
"modified": false
},
"sess_redlci17vpou7obl939u3p81ov": {
"type": "-",
"size": 0,
"lmtime": 1771459815604,
"modified": false
},
"sess_6bvdsb449nmokurbt5upir865c": {
"type": "-",
"size": 84,
"lmtime": 1771459849455,
"modified": false
}
},
"tmp": {}
},
".htaccess": {
"type": "-",
"size": 275,
"lmtime": 1771459940730,
"modified": false
}
}
},
"$version": 1
}

12
.vscode/sftp.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "host700513.hostido.net.pl",
"host": "host700513.hostido.net.pl",
"protocol": "ftp",
"port": 21,
"username": "www@orderpro.projectpro.pl",
"password": "TcVVuQD2ppdGQWnwZv6j",
"remotePath": "/public_html",
"uploadOnSave": false,
"useTempFile": false,
"openSsh": false
}

View File

@@ -0,0 +1,94 @@
# orderPRO - Backlog mikro-zadania (MVP)
Data: 2026-02-19
Legenda statusow:
- `TODO` - do zrobienia
- `DOING` - w trakcie
- `DONE` - zakonczone
- `BLOCKED` - zablokowane
## Sprint 0 - Fundament
1. `DONE` Utworzyc strukture katalogow projektu (`public`, `src`, `config`, `database`, `storage`, `bin`).
2. `DONE` Dodac `composer.json` z podstawowymi bibliotekami.
3. `DONE` Przygotowac `public/index.php` jako front controller.
4. `DONE` Dodac prosty router i testowa trase `/health`.
5. `DONE` Dodac loader konfiguracji `.env`.
6. `TODO` Podlaczyc MySQL (polaczenie + test zapytania).
7. `TODO` Utworzyc migracje tabeli `users`.
8. `TODO` Dodac seed pierwszego uzytkownika admin.
9. `TODO` Przygotowac bazowy layout HTML panelu (`header`, `sidebar`, `content`).
10. `DONE` Przygotowac wyglad strony logowania (sam widok).
11. `DONE` Dodac formularz logowania (email + haslo + submit).
12. `DONE` Dodac endpoint POST logowania i walidacje danych.
13. `DONE` Dodac sesje uzytkownika po poprawnym logowaniu.
14. `DONE` Dodac middleware sprawdzajace zalogowanie.
15. `DONE` Dodac wylogowanie (`POST /logout`).
16. `DONE` Dodac komunikaty bledow logowania w widoku.
17. `DONE` Dodac CSRF token do formularzy.
18. `DONE` Dodac podstawowe logowanie bledow do pliku.
19. `DONE` Przeniesc teksty UI do systemu tlumaczen (`resources/lang/pl.php` + helper `$t()`).
20. `DONE` Dodac konfiguracje locale i podlaczyc translator do widokow i kontrolerow.
21. `DONE` Przeniesc style do SCSS i uruchomic kompilacje/minifikacje do `public/assets/css`.
22. `DONE` Dodac skrypty frontendowe do budowy styli (`npm run build:css`, `npm run watch:css`).
23. `DONE` Ujednolicic design panelu i logowania pod styl adsPRO.
## Sprint 1 - Zamowienia z wlasnego sklepu
24. `TODO` Utworzyc migracje `orders`, `order_items`, `order_addresses`.
25. `TODO` Dodac encje/repozytoria dla zamowien.
26. `TODO` Dodac ekran listy zamowien (`/orders`) z paginacja.
27. `TODO` Dodac ekran szczegolow zamowienia (`/orders/{id}`).
28. `TODO` Dodac zmiane statusu zamowienia przyciskiem.
29. `TODO` Dodac tabele `order_status_history`.
30. `TODO` Zapisywac historie kazdej zmiany statusu.
31. `TODO` Dodac konfiguracje kanalu "wlasny sklep" w tabeli `channels`.
32. `TODO` Dodac klienta API wlasnego sklepu.
33. `TODO` Dodac importer zamowien (reczne uruchomienie z panelu).
34. `TODO` Dodac deduplikacje zamowien po `external_order_id`.
35. `TODO` Dodac cron do cyklicznego importu.
## Sprint 2 - Allegro
36. `TODO` Utworzyc konfiguracje Allegro w `channels`.
37. `TODO` Dodac ekran podpiecia konta Allegro (OAuth start/callback).
38. `TODO` Zapisac i odswiezac tokeny Allegro.
39. `TODO` Dodac importer zamowien Allegro.
40. `TODO` Dodac mapowanie statusow Allegro -> statusy lokalne.
41. `TODO` Dodac logowanie bledow API Allegro do `sync_errors`.
## Sprint 3 - Apaczka
42. `TODO` Utworzyc migracje `shipments`, `shipment_labels`.
43. `TODO` Dodac konfiguracje API Apaczka.
44. `TODO` Dodac przycisk "Utworz przesylke" na szczegolach zamowienia.
45. `TODO` Dodac wysylke danych paczki do Apaczka.
46. `TODO` Zapisac numer tracking i status utworzenia.
47. `TODO` Dodac pobieranie etykiety PDF.
48. `TODO` Dodac cron odswiezajacy statusy przesylek.
## Sprint 4 - Dokumenty i magazyn uproszczony
49. `TODO` Utworzyc migracje `documents`, `inventory_items`, `inventory_reservations`.
50. `TODO` Dodac integracje z systemem dokumentow (API zewnetrzne).
51. `TODO` Dodac przycisk "Wystaw dokument" na szczegolach zamowienia.
52. `TODO` Zapisac identyfikator/link dokumentu z systemu zewnetrznego.
53. `TODO` Dodac rezerwacje stanu po imporcie zamowienia.
54. `TODO` Dodac zwolnienie rezerwacji po anulowaniu zamowienia.
55. `TODO` Dodac prosty widok stanow magazynowych.
## Sprint 5 - Stabilizacja
56. `TODO` Dodac tabele `jobs` i `failed_jobs`.
57. `TODO` Przeniesc ciezsze operacje integracyjne do jobow.
58. `TODO` Dodac retry z rosnacym opoznieniem.
59. `TODO` Dodac panel "Bledy synchronizacji".
60. `TODO` Dodac testy najwazniejszych flow (logowanie, import, przesylka).
61. `TODO` Dodac skrypt backupu bazy (cron nocny).
## Nastepne zadanie (start)
1. `TODO` Podlaczyc MySQL (polaczenie + test zapytania):
- klasa polaczenia PDO,
- konfiguracja z `.env`,
- prosty endpoint kontrolny DB.

213
DOCS/PLAN_PROJEKTU.md Normal file
View File

@@ -0,0 +1,213 @@
# orderPRO - Plan projektu (MVP)
Data: 2026-02-19
## 0. Stan aktualny (wdrozone)
- dziala logowanie/wylogowanie z sesja, middleware i CSRF,
- dziala routing HTTP i renderowanie widokow SSR (wlasny lekki rdzen),
- jest bazowy panel (`topbar + content`) oraz ekran logowania,
- teksty interfejsu zostaly przeniesione do tlumaczen (`resources/lang/pl.php`),
- style sa utrzymywane w SCSS (`resources/scss`) i kompilowane/minifikowane do `public/assets/css`,
- wyglad loginu i panelu zostal ujednolicony pod styl adsPRO (kolory, typografia, buttony, spacing).
## 1. Cel
Zbudowac uproszczony panel do obslugi zamowien (inspiracja: Baselinker/Apilo/Sellasist), dzialajacy na hostingu wspoldzielonym (DirectAdmin), bez pelnego frameworka PHP.
Zakres MVP:
- import zamowien z wlasnego sklepu i Allegro,
- obsluga zamowien w panelu (statusy, notatki),
- tworzenie przesylek przez Apaczka (recznie przez przyciski),
- integracja z zewnetrznym systemem faktur/paragonow,
- uproszczona synchronizacja stanow magazynowych.
## 2. Zalozenia techniczne
- PHP: 8.4
- Baza: MySQL 8.x
- Hosting: wspoldzielony (DirectAdmin), cron dostepny
- Skala startowa: do 50 zamowien dziennie
- Model: jedna firma (single-tenant)
- Styl wdrozenia: szybkie MVP
## 3. Architektura (bez pelnego frameworka)
Podejscie: modularny monolit + lekki wlasny rdzen.
Elementy:
- Router HTTP (wlasny)
- Kontrolery + serwisy domenowe
- Warstwa dostepu do danych (repozytoria)
- Widoki SSR (natywne PHP templates)
- Integracje przez adaptery API (Guzzle)
- Kolejka oparta o MySQL (`jobs`) uruchamiana z crona
Zalecane biblioteki Composer:
- obecnie brak dodatkowych bibliotek runtime (rdzen jest autorski),
- biblioteki integracyjne/testowe beda dodawane etapowo, gdy pojawia sie realne moduly integracji.
## 4. Struktura katalogow
```txt
orderPRO/
public/
index.php
assets/
css/
js/
img/
bootstrap/
app.php
container.php
config/
app.php
database.php
queue.php
integrations.php
src/
Core/
Http/
Routing/
Security/
Database/
Queue/
View/
Support/
Modules/
Auth/
Dashboard/
Orders/
Inventory/
Shipping/
Documents/
Integrations/
Store/
Allegro/
Apaczka/
Billing/
database/
migrations/
seeders/
storage/
logs/
cache/
sessions/
tmp/
bin/
cron_sync.php
cron_queue.php
cron_tracking.php
tests/
Unit/
Feature/
DOCS/
PLAN_PROJEKTU.md
BACKLOG_MIKROZADANIA.md
```
## 5. Glowne moduly MVP
1. Auth
- logowanie i wylogowanie
- reset hasla
- ochrona sesji i CSRF
2. Orders
- lista zamowien (filtry, statusy)
- szczegoly zamowienia
- notatki i historia zmian statusu
3. Integrations
- wlasny sklep: import zamowien
- Allegro: OAuth + import zamowien
- Apaczka: utworzenie przesylki i pobranie etykiety
- Billing: tworzenie dokumentow w zewnetrznym systemie
4. Inventory (uproszczone)
- magazyn logiczny (SKU, ilosc)
- rezerwacja przy zamowieniu
- zwolnienie/anulowanie rezerwacji
5. Jobs + Cron
- przetwarzanie zadan asynchronicznych
- retry i log bledow
## 6. Model danych (MVP)
Tabele:
- `users`
- `channels` (konfiguracje API: store/allegro/apaczka/billing)
- `orders`
- `order_items`
- `order_addresses`
- `order_status_history`
- `shipments`
- `shipment_labels`
- `documents` (ID/link w systemie zewnetrznym)
- `inventory_items`
- `inventory_reservations`
- `jobs`
- `failed_jobs`
- `sync_runs`
- `sync_errors`
- `webhook_events` (na przyszlosc)
## 7. Synchronizacja i cron
Konfiguracja na shared hostingu:
- co 5 min: `php bin/cron_sync.php` (import zamowien)
- co 1-2 min: `php bin/cron_queue.php` (obsluga jobow)
- co 15 min: `php bin/cron_tracking.php` (statusy przesylek)
Zasady:
- krotkie zadania (batch np. po 20-50 rekordow),
- timeouty i retry z backoff,
- logowanie kazdej proby integracji.
## 8. Integracje (kolejnosc)
1. Wlasny sklep (pierwszy connector)
2. Allegro (drugi connector)
3. Apaczka (tworzenie przesylki z przycisku)
4. System billingowy (faktura/paragon przez API)
## 9. Plan etapow
## Etap 0 - Fundament
- skeleton aplikacji
- routing, DI/container, konfiguracja `.env`
- baza i migracje
- logowanie uzytkownika
- layout panelu admin
## Etap 1 - Orders + wlasny sklep
- import zamowien
- lista i szczegoly
- reczna zmiana statusow
## Etap 2 - Allegro
- autoryzacja OAuth
- pobieranie i mapowanie zamowien
## Etap 3 - Apaczka
- tworzenie przesylki
- etykieta PDF
- zapis numeru tracking
## Etap 4 - Billing + stock
- wystawienie dokumentu przez API
- uproszczony stock sync
## Etap 5 - Stabilizacja
- retry, logi, obsluga bledow
- testy kluczowych flow
- backup i checklista produkcyjna
## 10. Wymagania niefunkcjonalne
- bezpieczenstwo:
- hashowanie hasel (`password_hash`)
- CSRF tokeny
- walidacja wejscia
- escaping danych w widokach
- audyt:
- logi dzialan uzytkownika i integracji
- wydajnosc:
- paginacja list
- indeksy DB pod filtry zamowien i statusow
## 11. Decyzje odlozone na pozniej
- multi-tenant (wiele firm)
- zaawansowana dokumentacja magazynowa (WZ/PZ)
- automatyka regul biznesowych
- migracja na VPS / Docker

2
DOCS/TODO.md Normal file
View File

@@ -0,0 +1,2 @@
1. [x] Wszystkie teksty trzymac w pliku z tlumaczeniami.
2. [x] Jako styli uzywac SCSS, ktore sa kompilowane i minifikowane do CSS.

37
bootstrap/app.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Core\Application;
use App\Core\Support\Env;
$basePath = dirname(__DIR__);
$vendorAutoload = $basePath . '/vendor/autoload.php';
if (is_file($vendorAutoload)) {
require $vendorAutoload;
} else {
spl_autoload_register(static function (string $class) use ($basePath): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$file = $basePath . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
}
});
}
Env::load($basePath . '/.env');
$config = [
'app' => require $basePath . '/config/app.php',
'auth' => require $basePath . '/config/auth.php',
];
$app = new Application($basePath, $config);
$app->boot();
return $app;

17
composer.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "orderpro/app",
"description": "orderPRO - lightweight order management panel",
"type": "project",
"license": "proprietary",
"require": {
"php": "^8.4"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"scripts": {
"serve": "php -S localhost:8000 -t public public/index.php"
}
}

19
config/app.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use App\Core\Support\Env;
return [
'name' => Env::get('APP_NAME', 'orderPRO'),
'env' => Env::get('APP_ENV', 'production'),
'debug' => Env::bool('APP_DEBUG', false),
'url' => Env::get('APP_URL', ''),
'locale' => Env::get('APP_LOCALE', 'pl'),
'session' => [
'name' => Env::get('SESSION_NAME', 'orderpro_session'),
'path' => dirname(__DIR__) . '/storage/sessions',
],
'view_path' => dirname(__DIR__) . '/resources/views',
'lang_path' => dirname(__DIR__) . '/resources/lang',
'log_path' => dirname(__DIR__) . '/storage/logs/app.log',
];

12
config/auth.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use App\Core\Support\Env;
return [
'admin_email' => Env::get('ADMIN_EMAIL', 'admin@orderpro.local'),
'admin_password_hash' => Env::get(
'ADMIN_PASSWORD_HASH',
'$2y$10$1eRQmrhEUWgKRZpG08dKOenG4eZrvLQnLdCUfKHrZ/5dzLvxpmRYC'
),
];

4
index.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require __DIR__ . '/public/index.php';

452
package-lock.json generated Normal file
View File

@@ -0,0 +1,452 @@
{
"name": "orderpro",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "orderpro",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"sass": "^1.97.3"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"dev": true,
"license": "MIT"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sass": {
"version": "1.97.3",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz",
"integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
}
}
}

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "orderpro",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build:css": "sass --style=compressed --no-source-map resources/scss/app.scss public/assets/css/app.css && sass --style=compressed --no-source-map resources/scss/login.scss public/assets/css/login.css",
"watch:css": "sass --watch --style=expanded --no-source-map resources/scss/app.scss:public/assets/css/app.css resources/scss/login.scss:public/assets/css/login.css"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"sass": "^1.97.3"
}
}

7
public/.htaccess Normal file
View File

@@ -0,0 +1,7 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^ index.php [QSA,L]

View File

@@ -0,0 +1 @@
:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #e2e8f0;--c-danger: #cc0000;--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06);--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;font-size:14px;color:var(--c-text);background:var(--c-bg)}a{color:var(--c-primary)}.topbar{height:56px;border-bottom:1px solid var(--c-border);background:var(--c-surface);display:flex;align-items:center;justify-content:space-between;padding:0 20px;position:sticky;top:0;z-index:100}.brand{font-size:22px;font-weight:300;letter-spacing:-0.02em;color:var(--c-text-strong)}.brand strong{font-weight:700}.logout-btn{min-height:38px;border:1px solid var(--c-border);border-radius:8px;padding:0 14px;font:inherit;font-weight:600;color:var(--c-text-strong);background:var(--c-surface);cursor:pointer;transition:border-color .2s ease,color .2s ease,background-color .2s ease}.logout-btn:hover{border-color:#cbd5e0;background:#f8fafc}.logout-btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.container{max-width:1120px;margin:24px auto;padding:0 18px 24px}.card{background:var(--c-surface);border-radius:10px;box-shadow:var(--shadow-card);padding:24px}.card h1{margin:0 0 10px;color:var(--c-text-strong);font-size:24px;font-weight:700}.muted{color:var(--c-muted)}.accent{color:var(--c-primary);font-weight:600}.btn{min-height:38px;padding:8px 16px;border:0;border-radius:8px;color:#fff;background:var(--c-primary);font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease}.btn:hover{background:var(--c-primary-dark)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring)}.form-control{width:100%;min-height:38px;border:1px solid var(--c-border);border-radius:8px;padding:7px 12px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}@media(max-width: 768px){.topbar{padding:0 14px}.brand{font-size:20px}.container{margin-top:16px;padding:0 14px 18px}.card{padding:18px}}

View File

@@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["../../../resources/scss/app.scss"],"names":[],"mappings":"AAAA,MACE,qBACA,0BACA,gBACA,qBACA,kBACA,yBACA,mBACA,oBACA,oBACA,6CACA,kDAGF,EACE,sBAGF,UAEE,gBAGF,KACE,SACA,2CACA,eACA,oBACA,uBAGF,EACE,uBAGF,QACE,YACA,wCACA,4BACA,aACA,mBACA,8BACA,eACA,gBACA,MACA,YAGF,OACE,eACA,gBACA,uBACA,2BAGF,cACE,gBAGF,YACE,gBACA,iCACA,kBACA,eACA,aACA,gBACA,2BACA,4BACA,eACA,0EAGF,kBACE,qBACA,mBAGF,0BACE,aACA,6BACA,8BAGF,WACE,iBACA,iBACA,oBAGF,MACE,4BACA,mBACA,8BACA,aAGF,SACE,gBACA,2BACA,eACA,gBAGF,OACE,qBAGF,QACE,uBACA,gBAGF,KACE,gBACA,iBACA,SACA,kBACA,WACA,4BACA,aACA,gBACA,qBACA,eACA,qCAGF,WACE,iCAGF,mBACE,aACA,6BAGF,cACE,WACA,gBACA,iCACA,kBACA,iBACA,aACA,2BACA,gBACA,qDAGF,oBACE,aACA,8BACA,6BAGF,yBACE,QACE,eAGF,OACE,eAGF,WACE,gBACA,oBAGF,MACE","file":"app.css"}

View File

@@ -0,0 +1 @@
:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-border: #e2e8f0;--c-muted: #718096;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.alert-error{margin-bottom:18px;padding:12px 14px;border-radius:8px;border:1px solid #fed7d7;background:#fff5f5;color:var(--c-danger);font-size:13px;min-height:44px}.alert-error-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}input[type=email],input[type=password]{width:100%;height:46px;border:2px solid var(--c-border);border-radius:8px;padding:0 14px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}input[type=email]::placeholder,input[type=password]::placeholder{color:#cbd5e0}input[type=email]:focus,input[type=password]:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}.submit-btn{margin-top:2px;height:48px;border:0;border-radius:8px;font:inherit;font-size:15px;font-weight:600;color:#fff;background:var(--c-primary);cursor:pointer;transition:background-color .2s ease,transform .1s ease}.submit-btn:hover{background:var(--c-primary-dark)}.submit-btn:active{transform:translateY(1px)}.submit-btn:focus-visible{outline:none;box-shadow:var(--focus-ring)}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}}

View File

@@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["../../../resources/scss/login.scss"],"names":[],"mappings":"AAAA,MACE,qBACA,0BACA,gBACA,qBACA,kBACA,yBACA,oBACA,mBACA,oBACA,kDACA,kDAGF,EACE,sBAGF,UAEE,gBAGF,KACE,SACA,2CACA,oBACA,uBACA,kBAGF,QACE,eACA,YACA,aACA,oBACA,kBACA,UACA,YACA,oBAGF,aACE,WACA,YACA,6FAGF,cACE,aACA,cACA,uFAGF,YACE,iBACA,aACA,mBACA,kBACA,kBACA,UAGF,YACE,WACA,gBACA,4BACA,iCACA,mBACA,8BACA,uBACA,oCAGF,cACE,mBAGF,aACE,qBACA,gBACA,iBACA,oBACA,yBACA,mBACA,cACA,eACA,gBACA,yBACA,qBAGF,GACE,SACA,2BACA,qCACA,iBACA,gBAGF,gBACE,gBACA,eACA,iBACA,qBAGF,aACE,mBACA,kBACA,kBACA,yBACA,mBACA,sBACA,eACA,gBAGF,yBACE,YAGF,YACE,aACA,SAGF,YACE,aACA,QAGF,aACE,2BACA,eACA,gBAGF,uCAEE,WACA,YACA,iCACA,kBACA,eACA,aACA,2BACA,gBACA,qDAGF,iEAEE,cAGF,mDAEE,aACA,8BACA,6BAGF,YACE,eACA,YACA,SACA,kBACA,aACA,eACA,gBACA,WACA,4BACA,eACA,wDAGF,kBACE,iCAGF,mBACE,0BAGF,0BACE,aACA,6BAGF,sBACE,KACE,UACA,2BAEF,GACE,UACA,yBAIJ,yBACE,YACE,kBAGF,YACE,uBAGF,GACE","file":"login.css"}

8
public/index.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
use App\Core\Application;
/** @var Application $app */
$app = require dirname(__DIR__) . '/bootstrap/app.php';
$app->run();

41
resources/lang/pl.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
return [
'brand' => [
'name_prefix' => 'order',
'name_suffix' => 'PRO',
'name_full' => 'orderPRO',
],
'meta' => [
'title_pattern' => 'orderPRO - :title',
'default_panel_title' => 'Panel',
'default_login_title' => 'Logowanie',
],
'actions' => [
'login' => 'Zaloguj sie',
'logout' => 'Wyloguj',
],
'auth' => [
'login' => [
'title' => 'Logowanie',
'heading' => 'Panel zarzadzania zamowieniami',
'subtitle' => 'Zaloguj sie, aby przejsc do obslugi zamowien i wysylek.',
'error_placeholder' => 'Miejsce na komunikat bledu logowania.',
'email_label' => 'Email',
'email_placeholder' => 'np. admin@firma.pl',
'password_label' => 'Haslo',
'password_placeholder' => 'Wpisz haslo',
],
'errors' => [
'csrf_expired' => 'Sesja formularza wygasla. Odswiez strone i sprobuj ponownie.',
'invalid_credentials_format' => 'Podaj poprawny email i haslo.',
'invalid_credentials' => 'Niepoprawny email lub haslo.',
],
],
'dashboard' => [
'title' => 'Dashboard',
'description' => 'Szkielet panelu jest gotowy. Kolejny krok: lista zamowien.',
'active_user_label' => 'Aktywny uzytkownik:',
],
];

171
resources/scss/app.scss Normal file
View File

@@ -0,0 +1,171 @@
:root {
--c-primary: #6690f4;
--c-primary-dark: #3164db;
--c-bg: #f4f6f9;
--c-surface: #ffffff;
--c-text: #4e5e6a;
--c-text-strong: #2d3748;
--c-muted: #718096;
--c-border: #e2e8f0;
--c-danger: #cc0000;
--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06);
--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
font-family: "Roboto", "Segoe UI", sans-serif;
font-size: 14px;
color: var(--c-text);
background: var(--c-bg);
}
a {
color: var(--c-primary);
}
.topbar {
height: 56px;
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
position: sticky;
top: 0;
z-index: 100;
}
.brand {
font-size: 22px;
font-weight: 300;
letter-spacing: -0.02em;
color: var(--c-text-strong);
}
.brand strong {
font-weight: 700;
}
.logout-btn {
min-height: 38px;
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 0 14px;
font: inherit;
font-weight: 600;
color: var(--c-text-strong);
background: var(--c-surface);
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
}
.logout-btn:hover {
border-color: #cbd5e0;
background: #f8fafc;
}
.logout-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-color: var(--c-primary);
}
.container {
max-width: 1120px;
margin: 24px auto;
padding: 0 18px 24px;
}
.card {
background: var(--c-surface);
border-radius: 10px;
box-shadow: var(--shadow-card);
padding: 24px;
}
.card h1 {
margin: 0 0 10px;
color: var(--c-text-strong);
font-size: 24px;
font-weight: 700;
}
.muted {
color: var(--c-muted);
}
.accent {
color: var(--c-primary);
font-weight: 600;
}
.btn {
min-height: 38px;
padding: 8px 16px;
border: 0;
border-radius: 8px;
color: #ffffff;
background: var(--c-primary);
font: inherit;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn:hover {
background: var(--c-primary-dark);
}
.btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.form-control {
width: 100%;
min-height: 38px;
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 7px 12px;
font: inherit;
color: var(--c-text-strong);
background: #ffffff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: var(--focus-ring);
}
@media (max-width: 768px) {
.topbar {
padding: 0 14px;
}
.brand {
font-size: 20px;
}
.container {
margin-top: 16px;
padding: 0 14px 18px;
}
.card {
padding: 18px;
}
}

214
resources/scss/login.scss Normal file
View File

@@ -0,0 +1,214 @@
:root {
--c-primary: #6690f4;
--c-primary-dark: #3164db;
--c-bg: #f4f6f9;
--c-surface: #ffffff;
--c-text: #4e5e6a;
--c-text-strong: #2d3748;
--c-border: #e2e8f0;
--c-muted: #718096;
--c-danger: #cc0000;
--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);
--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
font-family: "Roboto", "Segoe UI", sans-serif;
color: var(--c-text);
background: var(--c-bg);
overflow-x: hidden;
}
.bg-orb {
position: fixed;
width: 460px;
height: 460px;
border-radius: 999px;
filter: blur(28px);
z-index: 0;
opacity: 0.45;
pointer-events: none;
}
.bg-orb-left {
top: -200px;
left: -180px;
background: radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%);
}
.bg-orb-right {
right: -200px;
bottom: -220px;
background: radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%);
}
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 20px;
position: relative;
z-index: 1;
}
.login-card {
width: 100%;
max-width: 430px;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 12px;
box-shadow: var(--shadow-card);
padding: 34px 30px 28px;
animation: card-enter 420ms ease-out;
}
.login-header {
margin-bottom: 24px;
}
.login-badge {
display: inline-block;
margin: 0 0 14px;
padding: 5px 12px;
border-radius: 999px;
border: 1px solid #d9e2ff;
background: #eef2ff;
color: #3f5faf;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
h1 {
margin: 0;
color: var(--c-text-strong);
font-size: clamp(1.6rem, 2.5vw, 1.9rem);
line-height: 1.15;
font-weight: 700;
}
.login-subtitle {
margin: 10px 0 0;
font-size: 15px;
line-height: 1.55;
color: var(--c-muted);
}
.alert-error {
margin-bottom: 18px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid #fed7d7;
background: #fff5f5;
color: var(--c-danger);
font-size: 13px;
min-height: 44px;
}
.alert-error-placeholder {
opacity: 0.56;
}
.login-form {
display: grid;
gap: 16px;
}
.form-field {
display: grid;
gap: 7px;
}
.field-label {
color: var(--c-text-strong);
font-size: 13px;
font-weight: 600;
}
input[type="email"],
input[type="password"] {
width: 100%;
height: 46px;
border: 2px solid var(--c-border);
border-radius: 8px;
padding: 0 14px;
font: inherit;
color: var(--c-text-strong);
background: #ffffff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input[type="email"]::placeholder,
input[type="password"]::placeholder {
color: #cbd5e0;
}
input[type="email"]:focus,
input[type="password"]:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: var(--focus-ring);
}
.submit-btn {
margin-top: 2px;
height: 48px;
border: 0;
border-radius: 8px;
font: inherit;
font-size: 15px;
font-weight: 600;
color: #ffffff;
background: var(--c-primary);
cursor: pointer;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.submit-btn:hover {
background: var(--c-primary-dark);
}
.submit-btn:active {
transform: translateY(1px);
}
.submit-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 640px) {
.login-page {
padding: 18px 14px;
}
.login-card {
padding: 24px 20px 20px;
}
h1 {
font-size: 1.55rem;
}
}

View File

@@ -0,0 +1,46 @@
<section class="login-card" aria-labelledby="login-title">
<header class="login-header">
<p class="login-badge"><?= $e($t('brand.name_full')) ?></p>
<h1 id="login-title"><?= $e($t('auth.login.heading')) ?></h1>
<p class="login-subtitle"><?= $e($t('auth.login.subtitle')) ?></p>
</header>
<?php if (!empty($errorMessage)): ?>
<div class="alert-error" role="alert">
<?= $e($errorMessage) ?>
</div>
<?php else: ?>
<div class="alert-error alert-error-placeholder" aria-hidden="true">
<?= $e($t('auth.login.error_placeholder')) ?>
</div>
<?php endif; ?>
<form class="login-form" action="/login" method="post" novalidate>
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('auth.login.email_label')) ?></span>
<input
type="email"
name="email"
autocomplete="email"
required
placeholder="<?= $e($t('auth.login.email_placeholder')) ?>"
value="<?= $e($oldEmail ?? '') ?>"
>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('auth.login.password_label')) ?></span>
<input
type="password"
name="password"
autocomplete="current-password"
required
placeholder="<?= $e($t('auth.login.password_placeholder')) ?>"
>
</label>
<button type="submit" class="submit-btn"><?= $e($t('actions.login')) ?></button>
</form>
</section>

View File

@@ -0,0 +1,7 @@
<section class="card">
<h1><?= $e($t('dashboard.title')) ?></h1>
<p class="muted"><?= $e($t('dashboard.description')) ?></p>
<?php if (!empty($user['email'])): ?>
<p><?= $e($t('dashboard.active_user_label')) ?> <span class="accent"><?= $e($user['email']) ?></span></p>
<?php endif; ?>
</section>

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $e($t('meta.title_pattern', ['title' => (string) ($title ?? $t('meta.default_panel_title'))])) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/app.css">
</head>
<body>
<header class="topbar">
<div class="brand"><?= $e($t('brand.name_prefix')) ?><strong><?= $e($t('brand.name_suffix')) ?></strong></div>
<form action="/logout" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<button type="submit" class="logout-btn"><?= $e($t('actions.logout')) ?></button>
</form>
</header>
<main class="container">
<?= $content ?>
</main>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $e($t('meta.title_pattern', ['title' => (string) ($title ?? $t('meta.default_login_title'))])) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/login.css">
</head>
<body>
<div class="bg-orb bg-orb-left" aria-hidden="true"></div>
<div class="bg-orb bg-orb-right" aria-hidden="true"></div>
<main class="login-page">
<?= $content ?>
</main>
</body>
</html>

46
routes/web.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Core\Application;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\Security\Csrf;
use App\Modules\Auth\AuthController;
use App\Modules\Auth\AuthMiddleware;
return static function (Application $app): void {
$router = $app->router();
$template = $app->template();
$auth = $app->auth();
$translator = $app->translator();
$authController = new AuthController($template, $auth, $translator);
$authMiddleware = new AuthMiddleware($auth);
$router->get('/health', static fn (Request $request): Response => Response::json([
'status' => 'ok',
'app' => (string) $app->config('app.name', 'orderPRO'),
'timestamp' => date(DATE_ATOM),
]));
$router->get('/', static function (Request $request) use ($auth): Response {
return $auth->check()
? Response::redirect('/dashboard')
: Response::redirect('/login');
});
$router->get('/login', [$authController, 'showLogin']);
$router->post('/login', [$authController, 'login']);
$router->post('/logout', [$authController, 'logout'], [$authMiddleware]);
$router->get('/dashboard', static function (Request $request) use ($template, $auth, $translator): Response {
$user = $auth->user();
$html = $template->render('dashboard/index', [
'title' => $translator->get('dashboard.title'),
'user' => $user,
'csrfToken' => Csrf::token(),
], 'layouts/app');
return Response::html($html);
}, [$authMiddleware]);
};

171
src/Core/Application.php Normal file
View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Core;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Routing\Router;
use App\Core\Support\Logger;
use App\Core\Support\Session;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
final class Application
{
private Router $router;
private Template $template;
private AuthService $authService;
private Logger $logger;
private Translator $translator;
/**
* @param array<string, array<string, mixed>> $config
*/
public function __construct(
private readonly string $basePath,
private readonly array $config
) {
$this->router = new Router();
$this->translator = new Translator(
(string) $this->config('app.lang_path'),
(string) $this->config('app.locale', 'pl')
);
$this->template = new Template((string) $this->config('app.view_path'), $this->translator);
$this->authService = new AuthService((array) $this->config('auth', []));
$this->logger = new Logger((string) $this->config('app.log_path'));
}
public function boot(): void
{
$this->prepareDirectories();
$this->configureSession();
$this->registerErrorHandlers();
$routes = require $this->basePath . '/routes/web.php';
$routes($this);
}
public function run(): void
{
$request = Request::capture();
$response = $this->router->dispatch($request);
$response->send();
}
public function basePath(string $path = ''): string
{
if ($path === '') {
return $this->basePath;
}
return $this->basePath . '/' . ltrim($path, '/');
}
public function router(): Router
{
return $this->router;
}
public function template(): Template
{
return $this->template;
}
public function auth(): AuthService
{
return $this->authService;
}
public function logger(): Logger
{
return $this->logger;
}
public function translator(): Translator
{
return $this->translator;
}
public function config(string $key, mixed $default = null): mixed
{
$segments = explode('.', $key);
$value = $this->config;
foreach ($segments as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return $default;
}
$value = $value[$segment];
}
return $value;
}
private function prepareDirectories(): void
{
$required = [
$this->basePath('storage/logs'),
$this->basePath('storage/sessions'),
$this->basePath('storage/cache'),
$this->basePath('storage/tmp'),
];
foreach ($required as $directory) {
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
}
}
private function configureSession(): void
{
$sessionName = (string) $this->config('app.session.name', 'orderpro_session');
$sessionPath = (string) $this->config('app.session.path', $this->basePath('storage/sessions'));
if (is_dir($sessionPath)) {
session_save_path($sessionPath);
}
session_name($sessionName);
Session::start();
}
private function registerErrorHandlers(): void
{
$debug = (bool) $this->config('app.debug', false);
if ($debug) {
ini_set('display_errors', '1');
error_reporting(E_ALL);
} else {
ini_set('display_errors', '0');
error_reporting(E_ALL);
}
set_exception_handler(function (Throwable $exception) use ($debug): void {
$this->logger->error('Unhandled exception', [
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
]);
$message = $debug ? $exception->getMessage() : 'Internal server error';
Response::html($message, 500)->send();
});
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
$this->logger->error('PHP error', [
'severity' => $severity,
'message' => $message,
'file' => $file,
'line' => $line,
]);
return false;
});
}
}

58
src/Core/Http/Request.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Core\Http;
final class Request
{
/**
* @param array<string, mixed> $query
* @param array<string, mixed> $request
* @param array<string, mixed> $server
*/
public function __construct(
private readonly array $query,
private readonly array $request,
private readonly array $server
) {
}
public static function capture(): self
{
return new self($_GET, $_POST, $_SERVER);
}
public function method(): string
{
return strtoupper((string) ($this->server['REQUEST_METHOD'] ?? 'GET'));
}
public function path(): string
{
$uri = (string) ($this->server['REQUEST_URI'] ?? '/');
$path = (string) parse_url($uri, PHP_URL_PATH);
return $path === '' ? '/' : $path;
}
public function input(string $key, mixed $default = null): mixed
{
if (array_key_exists($key, $this->request)) {
return $this->request[$key];
}
if (array_key_exists($key, $this->query)) {
return $this->query[$key];
}
return $default;
}
/**
* @return array<string, mixed>
*/
public function all(): array
{
return array_merge($this->query, $this->request);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Core\Http;
final class Response
{
/**
* @param array<string, string> $headers
*/
public function __construct(
private readonly string $body = '',
private readonly int $status = 200,
private readonly array $headers = []
) {
}
public static function html(string $html, int $status = 200): self
{
return new self($html, $status, ['Content-Type' => 'text/html; charset=UTF-8']);
}
/**
* @param array<string, mixed> $payload
*/
public static function json(array $payload, int $status = 200): self
{
return new self(
(string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$status,
['Content-Type' => 'application/json; charset=UTF-8']
);
}
public static function redirect(string $location, int $status = 302): self
{
return new self('', $status, ['Location' => $location]);
}
public function send(): void
{
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}
echo $this->body;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Core\I18n;
use RuntimeException;
final class Translator
{
/** @var array<string, mixed> */
private array $messages;
public function __construct(
private readonly string $langPath,
private readonly string $locale = 'pl'
) {
$this->messages = $this->load($this->locale);
}
/**
* @param array<string, scalar> $replace
*/
public function get(string $key, array $replace = []): string
{
$value = $this->resolve($key);
if (!is_string($value) || $value === '') {
$value = $key;
}
foreach ($replace as $name => $replacement) {
$value = str_replace(':' . $name, (string) $replacement, $value);
}
return $value;
}
/**
* @return array<string, mixed>
*/
private function load(string $locale): array
{
$file = rtrim($this->langPath, '/\\') . '/' . $locale . '.php';
if (!is_file($file)) {
throw new RuntimeException('Translation file not found: ' . $file);
}
$messages = require $file;
if (!is_array($messages)) {
throw new RuntimeException('Translation file must return array: ' . $file);
}
return $messages;
}
private function resolve(string $key): mixed
{
$segments = explode('.', $key);
$value = $this->messages;
foreach ($segments as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
}
}

126
src/Core/Routing/Router.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Core\Routing;
use App\Core\Http\Request;
use App\Core\Http\Response;
use RuntimeException;
final class Router
{
/**
* @var array<int, array{method:string,path:string,handler:callable,middlewares:array<int, callable|object>}>
*/
private array $routes = [];
/**
* @param callable $handler
* @param array<int, callable|object> $middlewares
*/
public function get(string $path, callable $handler, array $middlewares = []): void
{
$this->add('GET', $path, $handler, $middlewares);
}
/**
* @param callable $handler
* @param array<int, callable|object> $middlewares
*/
public function post(string $path, callable $handler, array $middlewares = []): void
{
$this->add('POST', $path, $handler, $middlewares);
}
public function dispatch(Request $request): Response
{
$route = $this->find($request->method(), $request->path());
if ($route === null) {
return Response::html('Not found', 404);
}
$handler = $route['handler'];
$middlewares = $route['middlewares'];
$pipeline = array_reduce(
array_reverse($middlewares),
fn (callable $next, callable|object $middleware): callable => $this->wrapMiddleware($middleware, $next),
fn (Request $req): mixed => $handler($req)
);
$result = $pipeline($request);
if ($result instanceof Response) {
return $result;
}
if (is_string($result)) {
return Response::html($result);
}
if (is_array($result)) {
return Response::json($result);
}
return Response::html('');
}
/**
* @param callable $handler
* @param array<int, callable|object> $middlewares
*/
private function add(string $method, string $path, callable $handler, array $middlewares): void
{
$this->routes[] = [
'method' => strtoupper($method),
'path' => $this->normalizePath($path),
'handler' => $handler,
'middlewares' => $middlewares,
];
}
/**
* @return array{method:string,path:string,handler:callable,middlewares:array<int, callable|object>}|null
*/
private function find(string $method, string $path): ?array
{
$normalizedMethod = strtoupper($method);
$normalizedPath = $this->normalizePath($path);
foreach ($this->routes as $route) {
if ($route['method'] === $normalizedMethod && $route['path'] === $normalizedPath) {
return $route;
}
}
return null;
}
private function normalizePath(string $path): string
{
if ($path === '') {
return '/';
}
if ($path !== '/' && str_ends_with($path, '/')) {
return rtrim($path, '/');
}
return $path;
}
private function wrapMiddleware(callable|object $middleware, callable $next): callable
{
return function (Request $request) use ($middleware, $next): mixed {
if (is_callable($middleware)) {
return $middleware($request, $next);
}
if (method_exists($middleware, 'handle')) {
return $middleware->handle($request, $next);
}
throw new RuntimeException('Invalid middleware definition');
};
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Core\Security;
final class Csrf
{
private const SESSION_KEY = '_csrf_token';
public static function token(): string
{
if (empty($_SESSION[self::SESSION_KEY])) {
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
}
return (string) $_SESSION[self::SESSION_KEY];
}
public static function validate(?string $token): bool
{
if ($token === null || $token === '') {
return false;
}
$storedToken = (string) ($_SESSION[self::SESSION_KEY] ?? '');
if ($storedToken === '') {
return false;
}
return hash_equals($storedToken, $token);
}
}

59
src/Core/Support/Env.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Core\Support;
final class Env
{
public static function load(string $filePath): void
{
if (!is_file($filePath)) {
return;
}
$lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return;
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
$delimiter = strpos($line, '=');
if ($delimiter === false) {
continue;
}
$key = trim(substr($line, 0, $delimiter));
$value = trim(substr($line, $delimiter + 1));
$value = trim($value, "\"'");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
putenv($key . '=' . $value);
}
}
public static function get(string $key, ?string $default = null): ?string
{
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
if ($value === false || $value === null || $value === '') {
return $default;
}
return (string) $value;
}
public static function bool(string $key, bool $default = false): bool
{
$value = self::get($key);
if ($value === null) {
return $default;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Core\Support;
final class Flash
{
private const FLASH_KEY = '_flash';
public static function set(string $key, mixed $value): void
{
if (!isset($_SESSION[self::FLASH_KEY]) || !is_array($_SESSION[self::FLASH_KEY])) {
$_SESSION[self::FLASH_KEY] = [];
}
$_SESSION[self::FLASH_KEY][$key] = $value;
}
public static function get(string $key, mixed $default = null): mixed
{
if (
!isset($_SESSION[self::FLASH_KEY]) ||
!is_array($_SESSION[self::FLASH_KEY]) ||
!array_key_exists($key, $_SESSION[self::FLASH_KEY])
) {
return $default;
}
$value = $_SESSION[self::FLASH_KEY][$key];
unset($_SESSION[self::FLASH_KEY][$key]);
if (empty($_SESSION[self::FLASH_KEY])) {
unset($_SESSION[self::FLASH_KEY]);
}
return $value;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Core\Support;
final class Logger
{
public function __construct(private readonly string $logFile)
{
}
/**
* @param array<string, mixed> $context
*/
public function error(string $message, array $context = []): void
{
$this->write('ERROR', $message, $context);
}
/**
* @param array<string, mixed> $context
*/
public function info(string $message, array $context = []): void
{
$this->write('INFO', $message, $context);
}
/**
* @param array<string, mixed> $context
*/
private function write(string $level, string $message, array $context): void
{
$line = sprintf(
"[%s] %s %s %s%s",
date('Y-m-d H:i:s'),
$level,
$message,
$context === [] ? '' : json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
PHP_EOL
);
error_log($line, 3, $this->logFile);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Core\Support;
final class Session
{
public static function start(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
session_start([
'cookie_httponly' => true,
'cookie_secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'cookie_samesite' => 'Lax',
'use_strict_mode' => true,
]);
}
public static function regenerate(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
return;
}
session_regenerate_id(true);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Core\View;
use App\Core\I18n\Translator;
use RuntimeException;
final class Template
{
public function __construct(
private readonly string $viewPath,
private readonly Translator $translator
)
{
}
/**
* @param array<string, mixed> $data
*/
public function render(string $view, array $data = [], ?string $layout = null): string
{
$content = $this->renderFile($view, $data);
if ($layout === null) {
return $content;
}
return $this->renderFile($layout, array_merge($data, ['content' => $content]));
}
/**
* @param array<string, mixed> $data
*/
private function renderFile(string $view, array $data): string
{
$file = $this->resolve($view);
$e = static fn (mixed $value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
$translator = $this->translator;
$t = static fn (string $key, array $replace = []): string => $translator->get($key, $replace);
ob_start();
extract($data, EXTR_SKIP);
require $file;
$output = ob_get_clean();
if ($output === false) {
throw new RuntimeException('Cannot render view: ' . $view);
}
return $output;
}
private function resolve(string $view): string
{
$relative = str_replace(['\\', '.'], '/', $view);
$fullPath = rtrim($this->viewPath, '/\\') . '/' . trim($relative, '/\\') . '.php';
if (!is_file($fullPath)) {
throw new RuntimeException('View not found: ' . $view);
}
return $fullPath;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
final class AuthController
{
public function __construct(
private readonly Template $template,
private readonly AuthService $auth,
private readonly Translator $translator
) {
}
public function showLogin(Request $request): Response
{
if ($this->auth->check()) {
return Response::redirect('/dashboard');
}
$html = $this->template->render('auth/login', [
'title' => $this->translator->get('auth.login.title'),
'errorMessage' => Flash::get('error'),
'oldEmail' => (string) Flash::get('old_email', ''),
'csrfToken' => Csrf::token(),
], 'layouts/auth');
return Response::html($html);
}
public function login(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('error', $this->translator->get('auth.errors.csrf_expired'));
Flash::set('old_email', (string) $request->input('email', ''));
return Response::redirect('/login');
}
$email = strtolower(trim((string) $request->input('email', '')));
$password = (string) $request->input('password', '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
Flash::set('error', $this->translator->get('auth.errors.invalid_credentials_format'));
Flash::set('old_email', $email);
return Response::redirect('/login');
}
if (!$this->auth->attempt($email, $password)) {
Flash::set('error', $this->translator->get('auth.errors.invalid_credentials'));
Flash::set('old_email', $email);
return Response::redirect('/login');
}
return Response::redirect('/dashboard');
}
public function logout(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/login');
}
$this->auth->logout();
return Response::redirect('/login');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Core\Http\Request;
use App\Core\Http\Response;
final class AuthMiddleware
{
public function __construct(private readonly AuthService $auth)
{
}
public function __invoke(Request $request, callable $next): Response
{
if (!$this->auth->check()) {
return Response::redirect('/login');
}
$result = $next($request);
if ($result instanceof Response) {
return $result;
}
if (is_array($result)) {
return Response::json($result);
}
return Response::html((string) $result);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Core\Support\Session;
final class AuthService
{
private const SESSION_USER_KEY = 'auth_user';
/**
* @param array<string, mixed> $config
*/
public function __construct(private readonly array $config)
{
}
public function attempt(string $email, string $password): bool
{
$storedEmail = strtolower((string) ($this->config['admin_email'] ?? ''));
$storedHash = (string) ($this->config['admin_password_hash'] ?? '');
if ($storedEmail === '' || $storedHash === '') {
return false;
}
if (strtolower($email) !== $storedEmail) {
return false;
}
if (!password_verify($password, $storedHash)) {
return false;
}
Session::regenerate();
$_SESSION[self::SESSION_USER_KEY] = [
'email' => $storedEmail,
'login_at' => date(DATE_ATOM),
];
return true;
}
public function check(): bool
{
return isset($_SESSION[self::SESSION_USER_KEY]) && is_array($_SESSION[self::SESSION_USER_KEY]);
}
/**
* @return array<string, mixed>|null
*/
public function user(): ?array
{
if (!$this->check()) {
return null;
}
/** @var array<string, mixed> $user */
$user = $_SESSION[self::SESSION_USER_KEY];
return $user;
}
public function logout(): void
{
unset($_SESSION[self::SESSION_USER_KEY]);
Session::regenerate();
}
}