chore: initialize orderPRO with docs, i18n and scss asset pipeline
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
vendor/
|
||||
storage/logs/
|
||||
storage/sessions/
|
||||
storage/cache/
|
||||
storage/tmp/
|
||||
10
.htaccess
Normal file
10
.htaccess
Normal 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
17
.vscode/ftp-kr.json
vendored
Normal 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
291
.vscode/ftp-kr.sync.cache.json
vendored
Normal 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
12
.vscode/sftp.json
vendored
Normal 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
|
||||
}
|
||||
94
DOCS/BACKLOG_MIKROZADANIA.md
Normal file
94
DOCS/BACKLOG_MIKROZADANIA.md
Normal 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
213
DOCS/PLAN_PROJEKTU.md
Normal 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
2
DOCS/TODO.md
Normal 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
37
bootstrap/app.php
Normal 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
17
composer.json
Normal 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
19
config/app.php
Normal 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
12
config/auth.php
Normal 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
4
index.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/public/index.php';
|
||||
452
package-lock.json
generated
Normal file
452
package-lock.json
generated
Normal 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
18
package.json
Normal 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
7
public/.htaccess
Normal file
@@ -0,0 +1,7 @@
|
||||
RewriteEngine On
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
1
public/assets/css/app.css
Normal file
1
public/assets/css/app.css
Normal 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}}
|
||||
1
public/assets/css/app.css.map
Normal file
1
public/assets/css/app.css.map
Normal 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"}
|
||||
1
public/assets/css/login.css
Normal file
1
public/assets/css/login.css
Normal 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}}
|
||||
1
public/assets/css/login.css.map
Normal file
1
public/assets/css/login.css.map
Normal 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
8
public/index.php
Normal 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
41
resources/lang/pl.php
Normal 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
171
resources/scss/app.scss
Normal 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
214
resources/scss/login.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
46
resources/views/auth/login.php
Normal file
46
resources/views/auth/login.php
Normal 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>
|
||||
7
resources/views/dashboard/index.php
Normal file
7
resources/views/dashboard/index.php
Normal 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>
|
||||
25
resources/views/layouts/app.php
Normal file
25
resources/views/layouts/app.php
Normal 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>
|
||||
20
resources/views/layouts/auth.php
Normal file
20
resources/views/layouts/auth.php
Normal 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
46
routes/web.php
Normal 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
171
src/Core/Application.php
Normal 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
58
src/Core/Http/Request.php
Normal 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);
|
||||
}
|
||||
}
|
||||
50
src/Core/Http/Response.php
Normal file
50
src/Core/Http/Response.php
Normal 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;
|
||||
}
|
||||
}
|
||||
70
src/Core/I18n/Translator.php
Normal file
70
src/Core/I18n/Translator.php
Normal 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
126
src/Core/Routing/Router.php
Normal 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');
|
||||
};
|
||||
}
|
||||
}
|
||||
32
src/Core/Security/Csrf.php
Normal file
32
src/Core/Security/Csrf.php
Normal 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
59
src/Core/Support/Env.php
Normal 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);
|
||||
}
|
||||
}
|
||||
38
src/Core/Support/Flash.php
Normal file
38
src/Core/Support/Flash.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/Core/Support/Logger.php
Normal file
44
src/Core/Support/Logger.php
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/Core/Support/Session.php
Normal file
30
src/Core/Support/Session.php
Normal 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);
|
||||
}
|
||||
}
|
||||
65
src/Core/View/Template.php
Normal file
65
src/Core/View/Template.php
Normal 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;
|
||||
}
|
||||
}
|
||||
76
src/Modules/Auth/AuthController.php
Normal file
76
src/Modules/Auth/AuthController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
32
src/Modules/Auth/AuthMiddleware.php
Normal file
32
src/Modules/Auth/AuthMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
70
src/Modules/Auth/AuthService.php
Normal file
70
src/Modules/Auth/AuthService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user