mirror of
https://github.com/serialexperiments0815/Dilomarkt---Digitaler-Lokalmarkt.git
synced 2026-07-12 15:32:19 +00:00
Authentication & mini fixes
This commit is contained in:
parent
86273e1765
commit
93fe6fa5ca
31 changed files with 1726 additions and 84 deletions
BIN
Dilomarkt/Composer-Setup.exe
Normal file
BIN
Dilomarkt/Composer-Setup.exe
Normal file
Binary file not shown.
|
|
@ -57,3 +57,33 @@ ## Security Vulnerabilities
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
|
|
||||||
|
## SMTP Setup for Real Verification Emails
|
||||||
|
|
||||||
|
To deliver verification and password-reset emails to real inboxes (Gmail, Outlook, Yahoo, etc.), configure a real SMTP provider in `.env`.
|
||||||
|
|
||||||
|
Required `.env` values:
|
||||||
|
|
||||||
|
- `MAIL_MAILER=smtp`
|
||||||
|
- `MAIL_HOST=<your-smtp-host>`
|
||||||
|
- `MAIL_PORT=587`
|
||||||
|
- `MAIL_USERNAME=<your-smtp-username>`
|
||||||
|
- `MAIL_PASSWORD=<your-smtp-password>`
|
||||||
|
- `MAIL_ENCRYPTION=tls`
|
||||||
|
- `MAIL_FROM_ADDRESS=<verified-sender@your-domain>`
|
||||||
|
- `MAIL_FROM_NAME="Dilomarkt"`
|
||||||
|
- `QUEUE_CONNECTION=sync`
|
||||||
|
|
||||||
|
After changing `.env`, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan config:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Then test by registering with a real address and requesting password reset.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- SMTP credentials must belong to a provider that allows outbound mail to external domains.
|
||||||
|
- Use a verified sender address from that provider.
|
||||||
|
- Delivery can still be filtered by recipient spam rules; verify SPF/DKIM for best inbox placement.
|
||||||
|
|
|
||||||
178
Dilomarkt/dilomarkt-api/app/Http/Controllers/AuthController.php
Normal file
178
Dilomarkt/dilomarkt-api/app/Http/Controllers/AuthController.php
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
public function register(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'first_name' => ['required', 'string', 'max:255'],
|
||||||
|
'last_name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'email', 'unique:users,email'],
|
||||||
|
'password' => ['required', 'confirmed', 'min:8'],
|
||||||
|
'role' => ['required', 'in:buyer,seller'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = User::create([
|
||||||
|
'first_name' => $request->first_name,
|
||||||
|
'last_name' => $request->last_name,
|
||||||
|
'name' => trim($request->first_name . ' ' . $request->last_name),
|
||||||
|
'email' => $request->email,
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
'role' => $request->role,
|
||||||
|
'verification_code' => $code,
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->sendVerificationEmail($user, $code);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($user)) {
|
||||||
|
$user->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'email_failed',
|
||||||
|
'message' => 'Verifizierungsmail konnte nicht gesendet werden. Bitte später erneut versuchen.',
|
||||||
|
], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'verification_sent',
|
||||||
|
'message' => 'Bitte verifiziere deinen Account mit dem Code aus der E-Mail.',
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'code' => ['required', 'string', 'size:6'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::where('email', $request->email)->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return response()->json(['message' => 'Benutzer nicht gefunden.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->verification_code !== $request->code) {
|
||||||
|
return response()->json(['message' => 'Der Verifizierungscode ist ungültig.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'verification_code' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'verified',
|
||||||
|
'message' => 'Dein Konto wurde verifiziert.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::where('email', $request->email)->first();
|
||||||
|
|
||||||
|
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||||
|
return response()->json(['message' => 'Ungültige E-Mail oder falsches Passwort.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->hasVerifiedEmail()) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'not_verified',
|
||||||
|
'message' => 'Bitte verifiziere zuerst deinen Account.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = Str::random(40);
|
||||||
|
$user->forceFill(['api_token' => $token])->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'logged_in',
|
||||||
|
'token' => $token,
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'first_name' => $user->first_name,
|
||||||
|
'last_name' => $user->last_name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'role' => $user->role,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forgotPassword(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate(['email' => ['required', 'email']]);
|
||||||
|
|
||||||
|
$user = User::where('email', $request->email)->first();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$token = Str::random(60);
|
||||||
|
try {
|
||||||
|
$user->forceFill(['password_reset_token' => $token])->save();
|
||||||
|
$this->sendPasswordResetEmail($user, $token);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'email_failed',
|
||||||
|
'message' => 'Reset-E-Mail konnte nicht gesendet werden. Bitte später erneut versuchen.',
|
||||||
|
], 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'reset_sent',
|
||||||
|
'message' => 'Falls die E-Mail existiert, wurde ein Link gesendet.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'token' => ['required', 'string'],
|
||||||
|
'password' => ['required', 'confirmed', 'min:8'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::where('password_reset_token', $request->token)->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return response()->json(['message' => 'Ungültiger oder abgelaufener Link.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
'password_reset_token' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'password_reset',
|
||||||
|
'message' => 'Dein Passwort wurde erfolgreich geändert.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendVerificationEmail(User $user, string $code): void
|
||||||
|
{
|
||||||
|
$user->notify(new \App\Notifications\VerifyEmailNotification($code));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendPasswordResetEmail(User $user, string $token): void
|
||||||
|
{
|
||||||
|
$user->notify(new \App\Notifications\ResetPasswordNotification($token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class SellerController extends Controller
|
||||||
|
{
|
||||||
|
/** Resolve the authenticated seller from the Bearer token. */
|
||||||
|
private function auth(Request $request): ?User
|
||||||
|
{
|
||||||
|
$token = $request->bearerToken();
|
||||||
|
if (! $token) return null;
|
||||||
|
return User::where('api_token', $token)->where('role', 'seller')->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map common German zip codes to coordinates. */
|
||||||
|
private function resolvePlz(string $plz): array
|
||||||
|
{
|
||||||
|
$coords = [
|
||||||
|
'42103' => [51.2562, 7.1508], '42107' => [51.2637, 7.1362],
|
||||||
|
'42119' => [51.2700, 7.1800], '42277' => [51.2800, 7.2100],
|
||||||
|
'42651' => [51.1800, 7.0800], '42853' => [51.1800, 7.1900],
|
||||||
|
'40210' => [51.2217, 6.7762], '44135' => [51.5139, 7.4653],
|
||||||
|
'45127' => [51.4556, 7.0116], '47051' => [51.4344, 6.7623],
|
||||||
|
'50667' => [50.9333, 6.9500],
|
||||||
|
];
|
||||||
|
return $coords[$plz] ?? [51.2562, 7.1508];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/seller/shop — returns shop info + products for the authenticated seller. */
|
||||||
|
public function getShop(Request $request)
|
||||||
|
{
|
||||||
|
$user = $this->auth($request);
|
||||||
|
if (! $user) return response()->json(['message' => 'Nicht autorisiert.'], 401);
|
||||||
|
|
||||||
|
$shop = DB::table('providers')->where('user_id', $user->id)->first();
|
||||||
|
if (! $shop) {
|
||||||
|
return response()->json(['shop' => null, 'products' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = DB::table('products')->where('provider_id', $shop->id)->orderBy('id')->get();
|
||||||
|
return response()->json(['shop' => $shop, 'products' => $products]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/seller/shop — create or update the seller's shop profile. */
|
||||||
|
public function upsertShop(Request $request)
|
||||||
|
{
|
||||||
|
$user = $this->auth($request);
|
||||||
|
if (! $user) return response()->json(['message' => 'Nicht autorisiert.'], 401);
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'type' => ['required', 'in:Fachhandel,Baumarkt'],
|
||||||
|
'address' => ['required', 'string', 'max:200'],
|
||||||
|
'city' => ['required', 'string', 'max:100'],
|
||||||
|
'zip' => ['required', 'string', 'max:10'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$lat, $lng] = $this->resolvePlz($request->zip);
|
||||||
|
$initials = strtoupper(mb_substr($request->name, 0, 2));
|
||||||
|
$existing = DB::table('providers')->where('user_id', $user->id)->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
DB::table('providers')->where('id', $existing->id)->update([
|
||||||
|
'name' => $request->name,
|
||||||
|
'type' => $request->type,
|
||||||
|
'address' => $request->address,
|
||||||
|
'city' => $request->city,
|
||||||
|
'zip' => $request->zip,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'initials' => $initials,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$shop = DB::table('providers')->find($existing->id);
|
||||||
|
} else {
|
||||||
|
$id = DB::table('providers')->insertGetId([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => $request->name,
|
||||||
|
'type' => $request->type,
|
||||||
|
'address' => $request->address,
|
||||||
|
'city' => $request->city,
|
||||||
|
'zip' => $request->zip,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'initials' => $initials,
|
||||||
|
'since' => now()->year,
|
||||||
|
'verified' => false,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$shop = DB::table('providers')->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Shop gespeichert.', 'shop' => $shop]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/seller/products — add a new product to the seller's shop. */
|
||||||
|
public function addProduct(Request $request)
|
||||||
|
{
|
||||||
|
$user = $this->auth($request);
|
||||||
|
if (! $user) return response()->json(['message' => 'Nicht autorisiert.'], 401);
|
||||||
|
|
||||||
|
$shop = DB::table('providers')->where('user_id', $user->id)->first();
|
||||||
|
if (! $shop) return response()->json(['message' => 'Kein Shop eingerichtet.'], 422);
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'title' => ['required', 'string', 'max:200'],
|
||||||
|
'price' => ['required', 'numeric', 'min:0'],
|
||||||
|
'category' => ['required', 'string'],
|
||||||
|
'stock' => ['required', 'integer', 'min:0'],
|
||||||
|
'description' => ['required', 'string'],
|
||||||
|
'icon' => ['required', 'string', 'max:8'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$id = DB::table('products')->insertGetId([
|
||||||
|
'provider_id' => $shop->id,
|
||||||
|
'title' => $request->title,
|
||||||
|
'price' => $request->price,
|
||||||
|
'category' => $request->category,
|
||||||
|
'stock' => $request->stock,
|
||||||
|
'description' => $request->description,
|
||||||
|
'icon' => $request->icon,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Produkt hinzugefügt.', 'product' => DB::table('products')->find($id)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/seller/products/{id} — update one of the seller's products. */
|
||||||
|
public function updateProduct(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$user = $this->auth($request);
|
||||||
|
if (! $user) return response()->json(['message' => 'Nicht autorisiert.'], 401);
|
||||||
|
|
||||||
|
$shop = DB::table('providers')->where('user_id', $user->id)->first();
|
||||||
|
if (! $shop) return response()->json(['message' => 'Kein Shop eingerichtet.'], 422);
|
||||||
|
|
||||||
|
$product = DB::table('products')->where('id', $id)->where('provider_id', $shop->id)->first();
|
||||||
|
if (! $product) return response()->json(['message' => 'Produkt nicht gefunden.'], 404);
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'title' => ['required', 'string', 'max:200'],
|
||||||
|
'price' => ['required', 'numeric', 'min:0'],
|
||||||
|
'category' => ['required', 'string'],
|
||||||
|
'stock' => ['required', 'integer', 'min:0'],
|
||||||
|
'description' => ['required', 'string'],
|
||||||
|
'icon' => ['required', 'string', 'max:8'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('products')->where('id', $id)->update([
|
||||||
|
'title' => $request->title,
|
||||||
|
'price' => $request->price,
|
||||||
|
'category' => $request->category,
|
||||||
|
'stock' => $request->stock,
|
||||||
|
'description' => $request->description,
|
||||||
|
'icon' => $request->icon,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Produkt aktualisiert.', 'product' => DB::table('products')->find($id)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /api/seller/products/{id} — remove one of the seller's products. */
|
||||||
|
public function deleteProduct(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$user = $this->auth($request);
|
||||||
|
if (! $user) return response()->json(['message' => 'Nicht autorisiert.'], 401);
|
||||||
|
|
||||||
|
$shop = DB::table('providers')->where('user_id', $user->id)->first();
|
||||||
|
if (! $shop) return response()->json(['message' => 'Kein Shop eingerichtet.'], 422);
|
||||||
|
|
||||||
|
$deleted = DB::table('products')->where('id', $id)->where('provider_id', $shop->id)->delete();
|
||||||
|
if (! $deleted) return response()->json(['message' => 'Produkt nicht gefunden.'], 404);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Produkt gelöscht.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/seller/messages — returns all buyer conversations for this seller's shop. */
|
||||||
|
public function getMessages(Request $request)
|
||||||
|
{
|
||||||
|
$user = $this->auth($request);
|
||||||
|
if (! $user) return response()->json(['message' => 'Nicht autorisiert.'], 401);
|
||||||
|
|
||||||
|
$shop = DB::table('providers')->where('user_id', $user->id)->first();
|
||||||
|
if (! $shop) return response()->json(['conversations' => []]);
|
||||||
|
|
||||||
|
// All messages for this shop ordered newest first
|
||||||
|
$rows = DB::table('messages')
|
||||||
|
->join('products', 'messages.product_id', '=', 'products.id')
|
||||||
|
->leftJoin('users', 'messages.buyer_id', '=', 'users.id')
|
||||||
|
->where('messages.provider_id', $shop->id)
|
||||||
|
->select(
|
||||||
|
'messages.product_id',
|
||||||
|
'messages.buyer_id',
|
||||||
|
'messages.body as last_message',
|
||||||
|
'messages.sender as last_sender',
|
||||||
|
'messages.created_at as last_at',
|
||||||
|
'products.title as product_title',
|
||||||
|
DB::raw("COALESCE(users.first_name || ' ' || users.last_name, 'Unbekannt') as buyer_name")
|
||||||
|
)
|
||||||
|
->orderBy('messages.created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Keep only the most recent message per (product_id, buyer_id) pair
|
||||||
|
$seen = [];
|
||||||
|
$conversations = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$key = $row->product_id . '_' . $row->buyer_id;
|
||||||
|
if (! isset($seen[$key])) {
|
||||||
|
$seen[$key] = true;
|
||||||
|
$conversations[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['conversations' => $conversations]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,9 +19,15 @@ class User extends Authenticatable
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'role',
|
||||||
|
'verification_code',
|
||||||
|
'password_reset_token',
|
||||||
|
'api_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,4 +52,9 @@ protected function casts(): array
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasVerifiedEmail(): bool
|
||||||
|
{
|
||||||
|
return ! is_null($this->email_verified_at);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class ResetPasswordNotification extends Notification
|
||||||
|
{
|
||||||
|
public function __construct(private string $token)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$frontendUrl = env('FRONTEND_URL', 'http://localhost:5173');
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Passwort zurücksetzen bei Dilomarkt')
|
||||||
|
->greeting('Hallo!')
|
||||||
|
->line('Klicke auf den Link unten, um dein Passwort zurückzusetzen.')
|
||||||
|
->action('Passwort zurücksetzen', $frontendUrl . '/reset-password/' . $this->token)
|
||||||
|
->salutation('Mit freundlichen Grüßen, das Dilomarkt Team');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class VerifyEmailNotification extends Notification
|
||||||
|
{
|
||||||
|
public function __construct(private string $code)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Dein Verifizierungscode für Dilomarkt')
|
||||||
|
->greeting('Hallo!')
|
||||||
|
->line('Vielen Dank für deine Registrierung bei Dilomarkt.')
|
||||||
|
->line('Dein Verifizierungscode lautet: ' . $this->code)
|
||||||
|
->line('Bitte gib diesen Code in der App ein, um dein Konto zu aktivieren.')
|
||||||
|
->salutation('Mit freundlichen Grüßen, das Dilomarkt Team');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
return [
|
return [
|
||||||
'paths' => ['api/*'],
|
'paths' => ['api/*'],
|
||||||
'allowed_methods' => ['*'],
|
'allowed_methods' => ['*'],
|
||||||
'allowed_origins' => ['http://localhost:5173'],
|
'allowed_origins' => ['http://localhost:5173', 'http://localhost:5174'],
|
||||||
'allowed_origins_patterns' => [],
|
'allowed_origins_patterns' => [],
|
||||||
'allowed_headers' => ['*'],
|
'allowed_headers' => ['*'],
|
||||||
'exposed_headers' => [],
|
'exposed_headers' => [],
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,15 @@ public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('users', function (Blueprint $table) {
|
Schema::create('users', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
|
$table->string('first_name');
|
||||||
|
$table->string('last_name');
|
||||||
$table->string('name');
|
$table->string('name');
|
||||||
$table->string('email')->unique();
|
$table->string('email')->unique();
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
$table->string('password');
|
$table->string('password');
|
||||||
|
$table->string('role')->default('buyer');
|
||||||
|
$table->string('verification_code')->nullable();
|
||||||
|
$table->string('password_reset_token')->nullable();
|
||||||
$table->rememberToken();
|
$table->rememberToken();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void {
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('api_token', 40)->nullable()->unique()->after('password_reset_token');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void {
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('api_token');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void {
|
||||||
|
Schema::table('providers', function (Blueprint $table) {
|
||||||
|
// Nullable integer — no FK constraint to keep SQLite compatibility
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable()->after('id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void {
|
||||||
|
Schema::table('providers', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('user_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\ProductController;
|
use App\Http\Controllers\ProductController;
|
||||||
use App\Http\Controllers\ProviderController;
|
use App\Http\Controllers\ProviderController;
|
||||||
use App\Http\Controllers\MessageController;
|
use App\Http\Controllers\MessageController;
|
||||||
|
use App\Http\Controllers\SellerController;
|
||||||
|
|
||||||
|
Route::post('/auth/register', [AuthController::class, 'register']);
|
||||||
|
Route::post('/auth/verify', [AuthController::class, 'verify']);
|
||||||
|
Route::post('/auth/login', [AuthController::class, 'login']);
|
||||||
|
Route::post('/auth/forgot-password', [AuthController::class, 'forgotPassword']);
|
||||||
|
Route::post('/auth/reset-password', [AuthController::class, 'resetPassword']);
|
||||||
|
|
||||||
Route::get('/products', [ProductController::class, 'index']);
|
Route::get('/products', [ProductController::class, 'index']);
|
||||||
Route::get('/products/{id}', [ProductController::class, 'show']);
|
Route::get('/products/{id}', [ProductController::class, 'show']);
|
||||||
|
|
@ -10,3 +18,11 @@
|
||||||
Route::get('/providers/{id}', [ProviderController::class, 'show']);
|
Route::get('/providers/{id}', [ProviderController::class, 'show']);
|
||||||
Route::get('/messages', [MessageController::class, 'index']);
|
Route::get('/messages', [MessageController::class, 'index']);
|
||||||
Route::post('/messages', [MessageController::class, 'store']);
|
Route::post('/messages', [MessageController::class, 'store']);
|
||||||
|
|
||||||
|
// Seller — protected by Bearer token (role: seller)
|
||||||
|
Route::get('/seller/shop', [SellerController::class, 'getShop']);
|
||||||
|
Route::post('/seller/shop', [SellerController::class, 'upsertShop']);
|
||||||
|
Route::post('/seller/products', [SellerController::class, 'addProduct']);
|
||||||
|
Route::put('/seller/products/{id}', [SellerController::class, 'updateProduct']);
|
||||||
|
Route::delete('/seller/products/{id}', [SellerController::class, 'deleteProduct']);
|
||||||
|
Route::get('/seller/messages', [SellerController::class, 'getMessages']);
|
||||||
|
|
|
||||||
21
Dilomarkt/dilomarkt-api/test_notify.php
Normal file
21
Dilomarkt/dilomarkt-api/test_notify.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
require __DIR__ . '/vendor/autoload.php';
|
||||||
|
$app = require_once __DIR__ . '/bootstrap/app.php';
|
||||||
|
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||||
|
$kernel->bootstrap();
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\VerifyEmailNotification;
|
||||||
|
|
||||||
|
// Make a temporary fake user (not saved)
|
||||||
|
$user = new User();
|
||||||
|
$user->email = 'ebosak322@gmail.com';
|
||||||
|
$user->name = 'Test User';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user->notify(new VerifyEmailNotification('123456'));
|
||||||
|
echo "SUCCESS: Notification sent via notify()\n";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo "FAILED via notify(): " . get_class($e) . "\n";
|
||||||
|
echo $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
39
Dilomarkt/dilomarkt-api/tests/Feature/AuthTest.php
Normal file
39
Dilomarkt/dilomarkt-api/tests/Feature/AuthTest.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AuthTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_user_can_register_and_verify_account(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/auth/register', [
|
||||||
|
'first_name' => 'Ada',
|
||||||
|
'last_name' => 'Lovelace',
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'password' => 'Password123!',
|
||||||
|
'password_confirmation' => 'Password123!',
|
||||||
|
'role' => 'buyer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonPath('status', 'verification_sent');
|
||||||
|
|
||||||
|
$user = User::where('email', 'ada@example.com')->first();
|
||||||
|
$this->assertNotNull($user);
|
||||||
|
$this->assertNotNull($user->verification_code);
|
||||||
|
|
||||||
|
$verifyResponse = $this->postJson('/api/auth/verify', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'code' => $user->verification_code,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$verifyResponse->assertOk();
|
||||||
|
$this->assertNotNull($user->fresh()->email_verified_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
Dilomarkt/package-lock.json
generated
99
Dilomarkt/package-lock.json
generated
|
|
@ -57,6 +57,7 @@
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
|
|
@ -481,33 +482,10 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -689,9 +667,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -709,9 +684,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -729,9 +701,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -749,9 +718,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -769,9 +735,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -789,9 +752,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -837,6 +797,40 @@
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
||||||
|
|
@ -908,6 +902,7 @@
|
||||||
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1330,6 +1325,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
|
|
@ -1875,9 +1871,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1899,9 +1892,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1923,9 +1913,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1947,9 +1934,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2513,6 +2497,7 @@
|
||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -2601,6 +2586,7 @@
|
||||||
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
|
|
@ -2789,6 +2775,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz",
|
||||||
"integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
|
"integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.35",
|
"@vue/compiler-dom": "3.5.35",
|
||||||
"@vue/compiler-sfc": "3.5.35",
|
"@vue/compiler-sfc": "3.5.35",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,32 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dilomarkt-app">
|
<div class="dilomarkt-app">
|
||||||
|
|
||||||
|
<!-- Top header -->
|
||||||
|
<header class="top-header">
|
||||||
|
<span class="top-logo" @click="router.push('/')">Dilomarkt</span>
|
||||||
|
<div class="top-right">
|
||||||
|
<!-- Not logged in -->
|
||||||
|
<template v-if="!user">
|
||||||
|
<button class="btn-primary top-btn" @click="router.push('/login')">Anmelden</button>
|
||||||
|
<button class="top-btn-outline" @click="router.push('/register')">Registrieren</button>
|
||||||
|
</template>
|
||||||
|
<!-- Logged in: avatar -->
|
||||||
|
<div v-else class="avatar-wrapper" ref="avatarRef">
|
||||||
|
<button class="avatar-btn" @click="toggleDropdown">{{ initials }}</button>
|
||||||
|
<div v-if="dropdownOpen" class="avatar-dropdown">
|
||||||
|
<div class="dropdown-name">{{ user.first_name }} {{ user.last_name }}</div>
|
||||||
|
<div class="dropdown-role">{{ user.role === 'seller' ? 'Verkäufer' : 'Käufer' }}</div>
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
<button @click="goTo('/profil')">👤 Mein Profil</button>
|
||||||
|
<button v-if="user.role === 'seller'" @click="goTo('/mein-shop')">🏪 Mein Shop</button>
|
||||||
|
<button @click="goTo('/bestellungen')">📦 Meine Bestellungen</button>
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
<button class="dropdown-logout" @click="logout">🚪 Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|
||||||
<nav class="mobile-bottom-nav">
|
<nav class="mobile-bottom-nav">
|
||||||
|
|
@ -10,9 +37,47 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAuth } from '@/useAuth.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const dropdownOpen = ref(false)
|
||||||
|
const avatarRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const { user, setUser } = useAuth()
|
||||||
|
|
||||||
|
const initials = computed(() => {
|
||||||
|
if (!user.value) return ''
|
||||||
|
const f = (user.value.first_name ?? '').charAt(0).toUpperCase()
|
||||||
|
const l = (user.value.last_name ?? '').charAt(0).toUpperCase()
|
||||||
|
return f + l
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleDropdown() {
|
||||||
|
dropdownOpen.value = !dropdownOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(path: string) {
|
||||||
|
dropdownOpen.value = false
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
dropdownOpen.value = false
|
||||||
|
setUser(null)
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOutsideClick(e: MouseEvent) {
|
||||||
|
if (avatarRef.value && !avatarRef.value.contains(e.target as Node)) {
|
||||||
|
dropdownOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('click', handleOutsideClick))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('click', handleOutsideClick))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -84,4 +149,49 @@ body {
|
||||||
.products-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
|
.products-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
|
||||||
.profile-stats { flex-wrap: wrap; }
|
.profile-stats { flex-wrap: wrap; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }
|
||||||
|
.dashboard-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap:16px; }
|
||||||
|
.dashboard-card { background: var(--bg-dark-card); border: 1px solid var(--border-color); border-radius: 10px; padding: 18px; }
|
||||||
|
.dashboard-actions { display:flex; flex-wrap:wrap; gap:10px; }
|
||||||
|
|
||||||
|
/* Top header */
|
||||||
|
.top-header { position: sticky; top: 0; z-index: 200; display: flex; justify-content: space-between; align-items: center; padding: 0 20px; height: 56px; background: #1a1a1a; border-bottom: 1px solid var(--border-color); }
|
||||||
|
.top-logo { font-size: 18px; font-weight: bold; color: var(--primary-accent); cursor: pointer; letter-spacing: 0.5px; }
|
||||||
|
.top-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.top-btn { padding: 7px 14px; font-size: 13px; }
|
||||||
|
.top-btn-outline { background: transparent; border: 1px solid var(--border-color); color: var(--text-main); padding: 7px 14px; border-radius: 6px; font-size: 13px; cursor: pointer; }
|
||||||
|
.top-btn-outline:hover { border-color: var(--primary-accent); color: var(--primary-accent); }
|
||||||
|
|
||||||
|
/* Avatar & dropdown */
|
||||||
|
.avatar-wrapper { position: relative; }
|
||||||
|
.avatar-btn { width: 38px; height: 38px; border-radius: 50%; background: var(--primary-accent); border: none; color: white; font-weight: bold; font-size: 14px; cursor: pointer; }
|
||||||
|
.avatar-dropdown { position: absolute; right: 0; top: 48px; background: #1e1e1e; border: 1px solid var(--border-color); border-radius: 10px; width: 210px; padding: 12px 0; box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 300; }
|
||||||
|
.dropdown-name { padding: 4px 16px; font-weight: bold; font-size: 14px; }
|
||||||
|
.dropdown-role { padding: 2px 16px 8px; font-size: 12px; color: #888; }
|
||||||
|
.dropdown-divider { border: none; border-top: 1px solid var(--border-color); margin: 4px 0; }
|
||||||
|
.avatar-dropdown button { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text-main); padding: 10px 16px; font-size: 14px; cursor: pointer; }
|
||||||
|
.avatar-dropdown button:hover { background: #2a2a2a; }
|
||||||
|
.dropdown-logout { color: #ef5350 !important; }
|
||||||
|
|
||||||
|
.view-container { max-width: 1200px; margin: 0 auto; padding: 20px; padding-bottom: 90px; padding-top: 20px; }
|
||||||
|
.auth-shell { display: flex; justify-content: center; align-items: center; min-height: 80vh; }
|
||||||
|
.auth-card { background: var(--bg-dark-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 24px; width: min(100%, 480px); box-shadow: 0 10px 30px rgba(0,0,0,.25); }
|
||||||
|
.auth-card h2 { margin-top: 0; }
|
||||||
|
.auth-subtitle { color: #aaa; margin-bottom: 16px; }
|
||||||
|
.auth-form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.auth-form input { width: 100%; box-sizing: border-box; background: #2a2a2a; border: 1px solid var(--border-color); color: white; padding: 12px; border-radius: 6px; }
|
||||||
|
.auth-row { display: flex; gap: 10px; }
|
||||||
|
.auth-row input { flex: 1; }
|
||||||
|
.password-field { position: relative; }
|
||||||
|
.password-field input { padding-right: 44px; }
|
||||||
|
.eye-btn { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: transparent; border: none; color: #aaa; cursor: pointer; }
|
||||||
|
.role-choice { display: flex; gap: 16px; align-items: center; color: #ccc; }
|
||||||
|
.auth-submit { width: 100%; margin-top: 4px; }
|
||||||
|
.status-message { margin-top: 12px; color: var(--primary-accent); font-size: 14px; }
|
||||||
|
.field-block { display:flex; flex-direction:column; gap:4px; }
|
||||||
|
.error-text { color:#ff8a80; font-size:12px; }
|
||||||
|
.helper-text { color:#888; font-size:12px; }
|
||||||
|
.auth-footer { margin-top: 12px; font-size: 14px; color: #aaa; }
|
||||||
|
.auth-footer a, .auth-footer router-link { color: var(--primary-accent); text-decoration: none; }
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,5 +1,32 @@
|
||||||
const BASE = 'http://localhost:8000/api'
|
const BASE = 'http://localhost:8000/api'
|
||||||
|
|
||||||
|
// ── Seller API (requires Bearer token) ───────────────────────────────────────
|
||||||
|
function sellerHeaders() {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('dilomarkt_token') || ''}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sellerFetch(url, method = 'GET', payload = null) {
|
||||||
|
const opts = { method, headers: sellerHeaders() }
|
||||||
|
if (payload) opts.body = JSON.stringify(payload)
|
||||||
|
const res = await fetch(url, opts)
|
||||||
|
let data
|
||||||
|
try { data = await res.json() } catch { data = {} }
|
||||||
|
if (!res.ok) throw new Error(data.message || 'Fehler')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSellerShop = () => sellerFetch(`${BASE}/seller/shop`)
|
||||||
|
export const saveSellerShop = (p) => sellerFetch(`${BASE}/seller/shop`, 'POST', p)
|
||||||
|
export const addSellerProduct = (p) => sellerFetch(`${BASE}/seller/products`, 'POST', p)
|
||||||
|
export const updateSellerProduct = (id, p) => sellerFetch(`${BASE}/seller/products/${id}`, 'PUT', p)
|
||||||
|
export const deleteSellerProduct = (id) => sellerFetch(`${BASE}/seller/products/${id}`, 'DELETE')
|
||||||
|
export const getSellerMessages = () => sellerFetch(`${BASE}/seller/messages`)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function fetchProducts(params = {}) {
|
export async function fetchProducts(params = {}) {
|
||||||
const q = new URLSearchParams(params).toString()
|
const q = new URLSearchParams(params).toString()
|
||||||
const res = await fetch(`${BASE}/products?${q}`)
|
const res = await fetch(`${BASE}/products?${q}`)
|
||||||
|
|
@ -40,3 +67,19 @@ export async function sendMessage(payload) {
|
||||||
if (!res.ok) throw new Error('Nachricht konnte nicht gesendet werden')
|
if (!res.ok) throw new Error('Nachricht konnte nicht gesendet werden')
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTH_HEADERS = { 'Content-Type': 'application/json', 'Accept': 'application/json' }
|
||||||
|
|
||||||
|
async function authFetch(url, payload) {
|
||||||
|
const res = await fetch(url, { method: 'POST', headers: AUTH_HEADERS, body: JSON.stringify(payload) })
|
||||||
|
let data
|
||||||
|
try { data = await res.json() } catch { data = {} }
|
||||||
|
if (!res.ok) throw new Error(data.message || 'Ein Fehler ist aufgetreten')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const registerUser = (p) => authFetch(`${BASE}/auth/register`, p)
|
||||||
|
export const verifyUser = (p) => authFetch(`${BASE}/auth/verify`, p)
|
||||||
|
export const loginUser = (p) => authFetch(`${BASE}/auth/login`, p)
|
||||||
|
export const forgotPassword = (p) => authFetch(`${BASE}/auth/forgot-password`, p)
|
||||||
|
export const resetPassword = (p) => authFetch(`${BASE}/auth/reset-password`, p)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@ import HomeView from '@/views/HomeView.vue'
|
||||||
import SearchView from '@/views/SearchView.vue'
|
import SearchView from '@/views/SearchView.vue'
|
||||||
import ProductView from '@/views/ProductView.vue'
|
import ProductView from '@/views/ProductView.vue'
|
||||||
import ProviderView from '@/views/ProviderView.vue'
|
import ProviderView from '@/views/ProviderView.vue'
|
||||||
|
import RegisterView from '@/views/RegisterView.vue'
|
||||||
|
import VerifyView from '@/views/VerifyView.vue'
|
||||||
|
import LoginView from '@/views/LoginView.vue'
|
||||||
|
import ResetPasswordView from '@/views/ResetPasswordView.vue'
|
||||||
|
import DashboardView from '@/views/DashboardView.vue'
|
||||||
|
import ProfileView from '@/views/ProfileView.vue'
|
||||||
|
import OrdersView from '@/views/OrdersView.vue'
|
||||||
|
import SellerView from '@/views/SellerView.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|
@ -12,6 +20,14 @@ const router = createRouter({
|
||||||
{ path: '/suche', name: 'search', component: SearchView },
|
{ path: '/suche', name: 'search', component: SearchView },
|
||||||
{ path: '/produkt/:id', name: 'product', component: ProductView },
|
{ path: '/produkt/:id', name: 'product', component: ProductView },
|
||||||
{ path: '/anbieter/:id', name: 'provider', component: ProviderView },
|
{ path: '/anbieter/:id', name: 'provider', component: ProviderView },
|
||||||
|
{ path: '/register', name: 'register', component: RegisterView },
|
||||||
|
{ path: '/verify', name: 'verify', component: VerifyView },
|
||||||
|
{ path: '/login', name: 'login', component: LoginView },
|
||||||
|
{ path: '/reset-password/:token', name: 'reset-password', component: ResetPasswordView },
|
||||||
|
{ path: '/dashboard', name: 'dashboard', component: DashboardView },
|
||||||
|
{ path: '/profil', name: 'profile', component: ProfileView },
|
||||||
|
{ path: '/bestellungen', name: 'orders', component: OrdersView },
|
||||||
|
{ path: '/mein-shop', name: 'seller', component: SellerView },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
24
Dilomarkt/src/useAuth.js
Normal file
24
Dilomarkt/src/useAuth.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// Module-level singleton — the same ref is shared across every component that imports this
|
||||||
|
const _user = ref(JSON.parse(localStorage.getItem('dilomarkt_user') || 'null'))
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
function setUser(u) {
|
||||||
|
_user.value = u
|
||||||
|
if (u) {
|
||||||
|
localStorage.setItem('dilomarkt_user', JSON.stringify(u))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('dilomarkt_user')
|
||||||
|
localStorage.removeItem('dilomarkt_token')
|
||||||
|
localStorage.removeItem('dilomarkt_seller_shop') // clear seller cache on logout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToken(token) {
|
||||||
|
if (token) localStorage.setItem('dilomarkt_token', token)
|
||||||
|
else localStorage.removeItem('dilomarkt_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user: _user, setUser, setToken }
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { fetchMessages, sendMessage } from '@/api.js'
|
import { fetchMessages, sendMessage } from '@/api.js'
|
||||||
|
import { useAuth } from '@/useAuth.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: Boolean,
|
open: Boolean,
|
||||||
|
|
@ -45,8 +46,8 @@ const props = defineProps({
|
||||||
})
|
})
|
||||||
defineEmits(['close'])
|
defineEmits(['close'])
|
||||||
|
|
||||||
// Hardcoded buyer_id=1 bis Auth fertig ist
|
const { user } = useAuth()
|
||||||
const BUYER_ID = 1
|
const buyerId = () => user.value?.id ?? null
|
||||||
|
|
||||||
const messages = ref([])
|
const messages = ref([])
|
||||||
const draft = ref('')
|
const draft = ref('')
|
||||||
|
|
@ -58,9 +59,10 @@ const listEl = ref(null)
|
||||||
let pollInterval = null
|
let pollInterval = null
|
||||||
|
|
||||||
async function loadMessages() {
|
async function loadMessages() {
|
||||||
|
if (!buyerId()) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
messages.value = await fetchMessages(props.productId, BUYER_ID)
|
messages.value = await fetchMessages(props.productId, buyerId())
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight
|
if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -78,7 +80,7 @@ async function send() {
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
product_id: props.productId,
|
product_id: props.productId,
|
||||||
provider_id: props.providerId,
|
provider_id: props.providerId,
|
||||||
buyer_id: BUYER_ID,
|
buyer_id: buyerId(),
|
||||||
sender: 'buyer',
|
sender: 'buyer',
|
||||||
body: draft.value.trim(),
|
body: draft.value.trim(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
59
Dilomarkt/src/views/DashboardView.vue
Normal file
59
Dilomarkt/src/views/DashboardView.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<template>
|
||||||
|
<div class="view-container">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<div>
|
||||||
|
<h2>Willkommen zurück</h2>
|
||||||
|
<p class="auth-subtitle">{{ userName }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" @click="logout">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<section class="dashboard-card">
|
||||||
|
<h3>Profil</h3>
|
||||||
|
<p><strong>Vorname:</strong> {{ profile.first_name }}</p>
|
||||||
|
<p><strong>Nachname:</strong> {{ profile.last_name }}</p>
|
||||||
|
<p><strong>E-Mail:</strong> {{ profile.email }}</p>
|
||||||
|
<p><strong>Rolle:</strong> {{ profile.role === 'seller' ? 'Verkäufer' : 'Käufer' }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-card">
|
||||||
|
<h3>Quick Actions</h3>
|
||||||
|
<div class="dashboard-actions">
|
||||||
|
<router-link class="btn-primary" to="/suche">Zur Suche</router-link>
|
||||||
|
<router-link class="btn-primary" to="/" style="background:#2a2a2a;border:1px solid var(--border-color)">Zum Start</router-link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-card">
|
||||||
|
<h3>{{ profile.role === 'seller' ? 'Verkäuferbereich' : 'Käuferbereich' }}</h3>
|
||||||
|
<p v-if="profile.role === 'seller'">Sie können Ihre Angebote verwalten und neue Produkte einstellen.</p>
|
||||||
|
<p v-else>Sie können Angebote vergleichen, Anfragen senden und regionale Anbieter entdecken.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const profile = computed(() => {
|
||||||
|
const raw = localStorage.getItem('dilomarkt_user')
|
||||||
|
return raw ? JSON.parse(raw) : { first_name: '', last_name: '', email: '', role: 'buyer' }
|
||||||
|
})
|
||||||
|
const userName = computed(() => `${profile.value.first_name} ${profile.value.last_name}`.trim() || 'Nutzer')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!localStorage.getItem('dilomarkt_user')) {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('dilomarkt_user')
|
||||||
|
localStorage.removeItem('dilomarkt_token')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { fetchProducts } from '@/api.js'
|
import { fetchProducts } from '@/api.js'
|
||||||
|
|
||||||
|
|
@ -62,15 +62,24 @@ const plz = ref('42103')
|
||||||
const cat = ref('')
|
const cat = ref('')
|
||||||
const radius = ref(10)
|
const radius = ref(10)
|
||||||
const types = ref([])
|
const types = ref([])
|
||||||
const products = ref([])
|
const allProducts = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
|
// All filtering is client-side — no API call on every filter change
|
||||||
|
const products = computed(() => {
|
||||||
|
let list = allProducts.value
|
||||||
|
if (radius.value < 20) list = list.filter(p => p.distance <= radius.value)
|
||||||
|
if (cat.value) list = list.filter(p => p.category === cat.value)
|
||||||
|
if (types.value.length) list = list.filter(p => types.value.includes(p.provider_type))
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
products.value = await fetchProducts({ plz: plz.value, radius: radius.value, q: q.value, category: cat.value })
|
allProducts.value = await fetchProducts({ plz: plz.value, radius: 20 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -78,7 +87,6 @@ async function load() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([radius, cat], load)
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
||||||
function doSearch() {
|
function doSearch() {
|
||||||
|
|
|
||||||
87
Dilomarkt/src/views/LoginView.vue
Normal file
87
Dilomarkt/src/views/LoginView.vue
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<template>
|
||||||
|
<div class="view-container auth-shell">
|
||||||
|
<div class="auth-card">
|
||||||
|
<button class="back-link" @click="router.back()">← Zurück</button>
|
||||||
|
<h2>Anmelden</h2>
|
||||||
|
<p class="auth-subtitle">Melde dich mit deiner E-Mail an.</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitLogin" class="auth-form">
|
||||||
|
<div class="field-block">
|
||||||
|
<input v-model="form.email" type="email" placeholder="E-Mail" @blur="validateEmail()" />
|
||||||
|
<small v-if="errors.email" class="error-text">{{ errors.email }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="field-block">
|
||||||
|
<div class="password-field">
|
||||||
|
<input :type="showPassword ? 'text' : 'password'" v-model="form.password" placeholder="Passwort" @blur="validatePassword()" />
|
||||||
|
<button type="button" class="eye-btn" @click="showPassword = !showPassword">{{ showPassword ? '🙈' : '👁️' }}</button>
|
||||||
|
</div>
|
||||||
|
<small v-if="errors.password" class="error-text">{{ errors.password }}</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary auth-submit" type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p v-if="message" class="status-message">{{ message }}</p>
|
||||||
|
<p class="auth-footer"><a href="#" @click.prevent="forgot">Passwort vergessen?</a></p>
|
||||||
|
<p class="auth-footer">Noch kein Konto? <router-link to="/register">Registrieren</router-link></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { forgotPassword, loginUser } from '@/api.js'
|
||||||
|
import { useAuth } from '@/useAuth.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { setUser, setToken } = useAuth()
|
||||||
|
const form = ref({ email: '', password: '' })
|
||||||
|
const message = ref('')
|
||||||
|
const errors = ref({})
|
||||||
|
const showPassword = ref(false)
|
||||||
|
|
||||||
|
function validateEmail() {
|
||||||
|
const next = { ...errors.value }
|
||||||
|
if (!form.value.email.trim()) next.email = 'E-Mail ist erforderlich.'
|
||||||
|
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.value.email)) next.email = 'Bitte eine gültige E-Mail eingeben.'
|
||||||
|
else delete next.email
|
||||||
|
errors.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePassword() {
|
||||||
|
const next = { ...errors.value }
|
||||||
|
if (!form.value.password) next.password = 'Passwort ist erforderlich.'
|
||||||
|
else delete next.password
|
||||||
|
errors.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLogin() {
|
||||||
|
message.value = ''
|
||||||
|
validateEmail()
|
||||||
|
validatePassword()
|
||||||
|
if (Object.keys(errors.value).length) return
|
||||||
|
try {
|
||||||
|
const data = await loginUser(form.value)
|
||||||
|
message.value = 'Erfolgreich angemeldet.'
|
||||||
|
setUser(data.user)
|
||||||
|
setToken(data.token)
|
||||||
|
router.push('/')
|
||||||
|
} catch (e) {
|
||||||
|
message.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forgot() {
|
||||||
|
if (!form.value.email) {
|
||||||
|
message.value = 'Bitte gib zuerst deine E-Mail ein.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await forgotPassword({ email: form.value.email })
|
||||||
|
message.value = data.message
|
||||||
|
router.push({ path: '/reset-password', query: { email: form.value.email } })
|
||||||
|
} catch (e) {
|
||||||
|
message.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
25
Dilomarkt/src/views/OrdersView.vue
Normal file
25
Dilomarkt/src/views/OrdersView.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<template>
|
||||||
|
<div class="view-container">
|
||||||
|
<h2>Meine Bestellungen</h2>
|
||||||
|
<div class="orders-empty">
|
||||||
|
<div class="orders-icon">📦</div>
|
||||||
|
<p>Du hast noch keine Bestellungen aufgegeben.</p>
|
||||||
|
<button class="btn-primary" @click="router.push('/suche')">Jetzt stöbern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuth } from '@/useAuth.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { user } = useAuth()
|
||||||
|
onMounted(() => { if (!user.value) router.push('/login') })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.orders-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 40vh; gap: 12px; color: #888; }
|
||||||
|
.orders-icon { font-size: 60px; }
|
||||||
|
</style>
|
||||||
52
Dilomarkt/src/views/ProfileView.vue
Normal file
52
Dilomarkt/src/views/ProfileView.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<template>
|
||||||
|
<div class="view-container">
|
||||||
|
<h2>Mein Profil</h2>
|
||||||
|
<div class="profile-info-card">
|
||||||
|
<div class="profile-avatar-lg">{{ initials }}</div>
|
||||||
|
<div class="profile-details">
|
||||||
|
<div class="profile-field">
|
||||||
|
<label>Vorname</label>
|
||||||
|
<span>{{ user.first_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-field">
|
||||||
|
<label>Nachname</label>
|
||||||
|
<span>{{ user.last_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-field">
|
||||||
|
<label>E-Mail</label>
|
||||||
|
<span>{{ user.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-field">
|
||||||
|
<label>Konto-Typ</label>
|
||||||
|
<span class="role-badge">{{ user.role === 'seller' ? '🏪 Verkäufer' : '🛒 Käufer' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuth } from '@/useAuth.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const initials = computed(() => {
|
||||||
|
if (!user.value) return '?'
|
||||||
|
return (user.value.first_name?.charAt(0) ?? '') + (user.value.last_name?.charAt(0) ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => { if (!user.value) router.push('/login') })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-info-card { background: var(--bg-dark-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 28px; display: flex; gap: 28px; align-items: flex-start; }
|
||||||
|
.profile-avatar-lg { width: 72px; height: 72px; border-radius: 50%; background: var(--primary-accent); display: flex; align-items: center; justify-content: center; font-size: 26px; font-weight: bold; color: white; flex-shrink: 0; text-transform: uppercase; }
|
||||||
|
.profile-details { flex: 1; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.profile-field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.profile-field label { font-size: 11px; color: #888; letter-spacing: 0.8px; text-transform: uppercase; }
|
||||||
|
.profile-field span { font-size: 15px; color: var(--text-main); }
|
||||||
|
.role-badge { display: inline-block; background: #2a2a2a; border: 1px solid var(--border-color); border-radius: 20px; padding: 4px 12px; font-size: 13px; }
|
||||||
|
@media (max-width: 480px) { .profile-info-card { flex-direction: column; align-items: center; text-align: center; } }
|
||||||
|
</style>
|
||||||
116
Dilomarkt/src/views/RegisterView.vue
Normal file
116
Dilomarkt/src/views/RegisterView.vue
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
<template>
|
||||||
|
<div class="view-container auth-shell">
|
||||||
|
<div class="auth-card">
|
||||||
|
<button class="back-link" @click="router.back()">← Zurück</button>
|
||||||
|
<h2>Registrieren</h2>
|
||||||
|
<p class="auth-subtitle">Erstelle dein Konto als Käufer oder Verkäufer.</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitRegister" class="auth-form">
|
||||||
|
<div class="auth-row">
|
||||||
|
<div class="field-block">
|
||||||
|
<input v-model="form.first_name" placeholder="Vorname" @blur="validateField('first_name')" />
|
||||||
|
<small v-if="errors.first_name" class="error-text">{{ errors.first_name }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="field-block">
|
||||||
|
<input v-model="form.last_name" placeholder="Nachname" @blur="validateField('last_name')" />
|
||||||
|
<small v-if="errors.last_name" class="error-text">{{ errors.last_name }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-block">
|
||||||
|
<input v-model="form.email" type="email" placeholder="E-Mail" @blur="validateField('email')" />
|
||||||
|
<small v-if="errors.email" class="error-text">{{ errors.email }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-block">
|
||||||
|
<div class="password-field">
|
||||||
|
<input :type="showPassword ? 'text' : 'password'" v-model="form.password" placeholder="Passwort" @blur="validateField('password')" />
|
||||||
|
<button type="button" class="eye-btn" @click="showPassword = !showPassword">{{ showPassword ? '🙈' : '👁️' }}</button>
|
||||||
|
</div>
|
||||||
|
<small v-if="errors.password" class="error-text">{{ errors.password }}</small>
|
||||||
|
<small v-else class="helper-text">Mindestens 8 Zeichen, inklusive Zahl und Sonderzeichen.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-block">
|
||||||
|
<div class="password-field">
|
||||||
|
<input :type="showConfirm ? 'text' : 'password'" v-model="form.password_confirmation" placeholder="Passwort wiederholen" @blur="validateField('password_confirmation')" />
|
||||||
|
<button type="button" class="eye-btn" @click="showConfirm = !showConfirm">{{ showConfirm ? '🙈' : '👁️' }}</button>
|
||||||
|
</div>
|
||||||
|
<small v-if="errors.password_confirmation" class="error-text">{{ errors.password_confirmation }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="role-choice">
|
||||||
|
<input type="radio" value="buyer" v-model="form.role" /> Käufer
|
||||||
|
<input type="radio" value="seller" v-model="form.role" /> Verkäufer
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="btn-primary auth-submit" type="submit">Jetzt registrieren</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p v-if="message" class="status-message">{{ message }}</p>
|
||||||
|
<p class="auth-footer">Schon ein Konto? <router-link to="/login">Anmelden</router-link></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { registerUser } from '@/api.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const form = ref({ first_name: '', last_name: '', email: '', password: '', password_confirmation: '', role: 'buyer' })
|
||||||
|
const message = ref('')
|
||||||
|
const errors = ref({})
|
||||||
|
const showPassword = ref(false)
|
||||||
|
const showConfirm = ref(false)
|
||||||
|
|
||||||
|
function validateField(field) {
|
||||||
|
const next = { ...errors.value }
|
||||||
|
if (field === 'first_name') {
|
||||||
|
if (!form.value.first_name.trim()) next.first_name = 'Vorname ist erforderlich.'
|
||||||
|
else delete next.first_name
|
||||||
|
} else if (field === 'last_name') {
|
||||||
|
if (!form.value.last_name.trim()) next.last_name = 'Nachname ist erforderlich.'
|
||||||
|
else delete next.last_name
|
||||||
|
} else if (field === 'email') {
|
||||||
|
if (!form.value.email.trim()) next.email = 'E-Mail ist erforderlich.'
|
||||||
|
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.value.email)) next.email = 'Bitte eine gültige E-Mail eingeben.'
|
||||||
|
else delete next.email
|
||||||
|
} else if (field === 'password') {
|
||||||
|
if (!form.value.password) next.password = 'Passwort ist erforderlich.'
|
||||||
|
else if (form.value.password.length < 8) next.password = 'Mindestens 8 Zeichen.'
|
||||||
|
else if (!/\d/.test(form.value.password) || !/[^A-Za-z0-9]/.test(form.value.password)) next.password = 'Bitte Zahl und Sonderzeichen verwenden.'
|
||||||
|
else delete next.password
|
||||||
|
} else if (field === 'password_confirmation') {
|
||||||
|
if (!form.value.password_confirmation) next.password_confirmation = 'Bitte Passwort wiederholen.'
|
||||||
|
else if (form.value.password_confirmation !== form.value.password) next.password_confirmation = 'Passwörter stimmen nicht überein.'
|
||||||
|
else delete next.password_confirmation
|
||||||
|
}
|
||||||
|
errors.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
errors.value = {} // clear any stale errors before a full run
|
||||||
|
validateField('first_name')
|
||||||
|
validateField('last_name')
|
||||||
|
validateField('email')
|
||||||
|
validateField('password')
|
||||||
|
validateField('password_confirmation')
|
||||||
|
return Object.keys(errors.value).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRegister() {
|
||||||
|
message.value = ''
|
||||||
|
if (!validateForm()) return
|
||||||
|
try {
|
||||||
|
const data = await registerUser(form.value)
|
||||||
|
message.value = data.message
|
||||||
|
if (data.status === 'verification_sent') {
|
||||||
|
router.push({ path: '/verify', query: { email: form.value.email } })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
35
Dilomarkt/src/views/ResetPasswordView.vue
Normal file
35
Dilomarkt/src/views/ResetPasswordView.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<template>
|
||||||
|
<div class="view-container auth-shell">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h2>Passwort zurücksetzen</h2>
|
||||||
|
<p class="auth-subtitle">Lege ein neues Passwort fest.</p>
|
||||||
|
<form @submit.prevent="submitReset" class="auth-form">
|
||||||
|
<input v-model="form.password" type="password" placeholder="Neues Passwort" required />
|
||||||
|
<input v-model="form.password_confirmation" type="password" placeholder="Passwort wiederholen" required />
|
||||||
|
<button class="btn-primary auth-submit" type="submit">Passwort ändern</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="message" class="status-message">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { resetPassword } from '@/api.js'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const form = ref({ password: '', password_confirmation: '' })
|
||||||
|
const message = ref('')
|
||||||
|
|
||||||
|
async function submitReset() {
|
||||||
|
try {
|
||||||
|
const data = await resetPassword({ token: route.params.token, password: form.value.password, password_confirmation: form.value.password_confirmation })
|
||||||
|
message.value = data.message
|
||||||
|
if (data.status === 'password_reset') router.push('/login')
|
||||||
|
} catch (e) {
|
||||||
|
message.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -2,28 +2,28 @@
|
||||||
<div class="view-container">
|
<div class="view-container">
|
||||||
<div class="search-container" style="max-width:100%;margin-bottom:16px">
|
<div class="search-container" style="max-width:100%;margin-bottom:16px">
|
||||||
<input v-model="q" @keyup.enter="load" placeholder="Suchen..." />
|
<input v-model="q" @keyup.enter="load" placeholder="Suchen..." />
|
||||||
<select v-model="cat" @change="load">
|
<select v-model="cat">
|
||||||
<option value="">Alle Kategorien</option>
|
<option value="">Alle Kategorien</option>
|
||||||
<option v-for="c in CATEGORIES" :key="c">{{ c }}</option>
|
<option v-for="c in CATEGORIES" :key="c">{{ c }}</option>
|
||||||
</select>
|
</select>
|
||||||
<input v-model="plz" class="location-input" placeholder="PLZ" @change="load" />
|
<input v-model="plz" class="location-input" placeholder="PLZ" @change="load" @keyup.enter="load" />
|
||||||
<button class="btn-primary" @click="load">Suchen</button>
|
<button class="btn-primary" @click="load">Suchen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="category-chips">
|
<div class="category-chips">
|
||||||
<span :class="['chip', !cat && 'active']" @click="cat='';load()">Alle</span>
|
<span :class="['chip', !cat && 'active']" @click="cat=''">Alle</span>
|
||||||
<span v-for="c in CATEGORIES" :key="c" :class="['chip', cat===c && 'active']" @click="cat=c;load()">{{ c }}</span>
|
<span v-for="c in CATEGORIES" :key="c" :class="['chip', cat===c && 'active']" @click="cat=c">{{ c }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="marketplace-body">
|
<div class="marketplace-body">
|
||||||
<aside class="filter-sidebar">
|
<aside class="filter-sidebar">
|
||||||
<h3>UMKREIS</h3>
|
<h3>UMKREIS</h3>
|
||||||
<label v-for="r in [5,10,20,50]" :key="r"><input type="radio" v-model="radius" :value="r" @change="load" /> {{ r }} km</label>
|
<label v-for="r in [5,10,20,50]" :key="r"><input type="radio" v-model="radius" :value="r" /> {{ r }} km</label>
|
||||||
<h3>ANBIETERTYP</h3>
|
<h3>ANBIETERTYP</h3>
|
||||||
<label><input type="checkbox" v-model="types" value="Fachhandel" @change="load" /> Fachhandel</label>
|
<label><input type="checkbox" v-model="types" value="Fachhandel" /> Fachhandel</label>
|
||||||
<label><input type="checkbox" v-model="types" value="Baumarkt" @change="load" /> Baumarkt</label>
|
<label><input type="checkbox" v-model="types" value="Baumarkt" /> Baumarkt</label>
|
||||||
<h3>PREIS BIS</h3>
|
<h3>PREIS BIS</h3>
|
||||||
<input type="range" v-model="maxPrice" min="1" max="500" style="width:100%" @change="load" />
|
<input type="range" v-model="maxPrice" min="1" max="500" style="width:100%" />
|
||||||
<span style="font-size:13px">max. {{ maxPrice }} €</span>
|
<span style="font-size:13px">max. {{ maxPrice }} €</span>
|
||||||
</aside>
|
</aside>
|
||||||
<main style="flex:1">
|
<main style="flex:1">
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { fetchProducts } from '@/api.js'
|
import { fetchProducts } from '@/api.js'
|
||||||
|
|
||||||
|
|
@ -69,17 +69,33 @@ const plz = ref(route.query.plz ?? '42103')
|
||||||
const radius = ref(20)
|
const radius = ref(20)
|
||||||
const maxPrice = ref(500)
|
const maxPrice = ref(500)
|
||||||
const types = ref([])
|
const types = ref([])
|
||||||
const products = ref([])
|
const allProducts = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
|
// All filtering is client-side — instant, no network round-trip per filter change
|
||||||
|
const products = computed(() => {
|
||||||
|
let list = allProducts.value
|
||||||
|
list = list.filter(p => p.distance <= Number(radius.value))
|
||||||
|
if (cat.value) list = list.filter(p => p.category === cat.value)
|
||||||
|
if (types.value.length) list = list.filter(p => types.value.includes(p.provider_type))
|
||||||
|
if (Number(maxPrice.value) < 500) list = list.filter(p => p.price <= Number(maxPrice.value))
|
||||||
|
if (q.value.trim()) {
|
||||||
|
const sq = q.value.trim().toLowerCase()
|
||||||
|
list = list.filter(p =>
|
||||||
|
p.title?.toLowerCase().includes(sq) ||
|
||||||
|
p.provider?.toLowerCase().includes(sq)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const params = { plz: plz.value, radius: radius.value, q: q.value, category: cat.value, max_price: maxPrice.value }
|
// Fetch broad dataset once; client-side computed handles the rest
|
||||||
if (types.value.length === 1) params.type = types.value[0]
|
allProducts.value = await fetchProducts({ plz: plz.value, radius: 50 })
|
||||||
products.value = await fetchProducts(params)
|
|
||||||
router.replace({ query: { q: q.value, cat: cat.value, plz: plz.value } })
|
router.replace({ query: { q: q.value, cat: cat.value, plz: plz.value } })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
|
|
@ -88,10 +104,10 @@ async function load() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => route.query, q => {
|
// Sync q/cat from route params (no reload — computed handles it)
|
||||||
q.value = route.query.q ?? ''
|
watch(() => route.query, (newQ) => {
|
||||||
cat.value = route.query.cat ?? ''
|
q.value = newQ.q ?? ''
|
||||||
load()
|
cat.value = newQ.cat ?? ''
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
|
||||||
408
Dilomarkt/src/views/SellerView.vue
Normal file
408
Dilomarkt/src/views/SellerView.vue
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
<template>
|
||||||
|
<div class="view-container">
|
||||||
|
<h2>Mein Shop</h2>
|
||||||
|
|
||||||
|
<div v-if="loading" style="color:#aaa;padding:20px">Lädt...</div>
|
||||||
|
<p v-if="pageMsg" :class="msgIsError ? 'msg-error' : 'msg-ok'">{{ pageMsg }}</p>
|
||||||
|
|
||||||
|
<!-- ── SHOP SECTION ────────────────────────────────── -->
|
||||||
|
<section class="seller-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3>Shop-Daten</h3>
|
||||||
|
<button v-if="shop && !showShopForm" class="btn-sm" @click="showShopForm = true">✏️ Bearbeiten</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info card (view mode) -->
|
||||||
|
<div v-if="shop && !showShopForm" class="shop-info-card">
|
||||||
|
<div class="shop-initials">{{ shop.initials }}</div>
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:17px">{{ shop.name }}</strong>
|
||||||
|
<div class="meta">{{ shop.type }} · {{ shop.address }}, {{ shop.zip }} {{ shop.city }}</div>
|
||||||
|
<span v-if="shop.verified" class="badge-ok">✓ Verifiziert</span>
|
||||||
|
<span v-else class="badge-pending">⏳ Noch nicht verifiziert</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form (create or edit) -->
|
||||||
|
<form v-if="(!shop && !loading) || showShopForm" @submit.prevent="submitShop" class="seller-form">
|
||||||
|
<p v-if="!shop" style="color:#aaa;margin-bottom:16px">
|
||||||
|
Richte deinen Shop ein, damit deine Produkte auf der Plattform erscheinen.
|
||||||
|
</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field-block">
|
||||||
|
<label>Shopname *</label>
|
||||||
|
<input v-model="shopForm.name" placeholder="z. B. Profi-Werkzeug GmbH" required />
|
||||||
|
</div>
|
||||||
|
<div class="field-block">
|
||||||
|
<label>Anbietertyp *</label>
|
||||||
|
<select v-model="shopForm.type">
|
||||||
|
<option>Fachhandel</option>
|
||||||
|
<option>Baumarkt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-block">
|
||||||
|
<label>Adresse *</label>
|
||||||
|
<input v-model="shopForm.address" placeholder="Musterstraße 12" required />
|
||||||
|
</div>
|
||||||
|
<div class="field-block">
|
||||||
|
<label>Stadt *</label>
|
||||||
|
<input v-model="shopForm.city" placeholder="Wuppertal" required />
|
||||||
|
</div>
|
||||||
|
<div class="field-block">
|
||||||
|
<label>Postleitzahl *</label>
|
||||||
|
<input v-model="shopForm.zip" placeholder="42103" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-primary" type="submit" :disabled="saving">
|
||||||
|
{{ saving ? 'Speichern…' : 'Shop speichern' }}
|
||||||
|
</button>
|
||||||
|
<button v-if="shop" type="button" class="btn-cancel" @click="showShopForm = false">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── PRODUCTS SECTION ────────────────────────────── -->
|
||||||
|
<section v-if="shop" class="seller-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3>Produkte <span class="count-badge">{{ products.length }}</span></h3>
|
||||||
|
<button v-if="!showProductForm" class="btn-primary btn-sm" @click="openAddProduct">+ Neues Produkt</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add / Edit product form -->
|
||||||
|
<form v-if="showProductForm" @submit.prevent="submitProduct" class="seller-form product-form">
|
||||||
|
<h4 style="margin:0 0 16px">{{ editingProductId ? 'Produkt bearbeiten' : 'Neues Produkt' }}</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field-block">
|
||||||
|
<label>Titel *</label>
|
||||||
|
<input v-model="productForm.title" placeholder="z. B. Bohrmaschine 18V" required />
|
||||||
|
</div>
|
||||||
|
<div class="field-block">
|
||||||
|
<label>Preis (€) *</label>
|
||||||
|
<input v-model="productForm.price" type="number" step="0.01" min="0" required />
|
||||||
|
</div>
|
||||||
|
<div class="field-block">
|
||||||
|
<label>Kategorie *</label>
|
||||||
|
<select v-model="productForm.category">
|
||||||
|
<option v-for="c in CATEGORIES" :key="c">{{ c }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-block">
|
||||||
|
<label>Lagerbestand *</label>
|
||||||
|
<input v-model="productForm.stock" type="number" min="0" required />
|
||||||
|
</div>
|
||||||
|
<div class="field-block">
|
||||||
|
<label>Icon (Emoji)</label>
|
||||||
|
<input v-model="productForm.icon" placeholder="📦" maxlength="4" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-block" style="margin-top:12px">
|
||||||
|
<label>Beschreibung *</label>
|
||||||
|
<textarea v-model="productForm.description" rows="3" placeholder="Details zum Angebot…" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-primary" type="submit" :disabled="saving">
|
||||||
|
{{ saving ? 'Speichern…' : 'Speichern' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-cancel" @click="closeProductForm">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="products.length === 0 && !showProductForm" class="empty-hint">
|
||||||
|
Noch keine Produkte. Füge dein erstes Angebot hinzu.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product rows -->
|
||||||
|
<div v-for="p in products" :key="p.id" class="product-row">
|
||||||
|
<span class="product-icon-cell">{{ p.icon }}</span>
|
||||||
|
<div class="product-info">
|
||||||
|
<strong>{{ p.title }}</strong>
|
||||||
|
<span class="meta">{{ p.category }} · {{ p.stock }} Stk. · {{ p.price }} €</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="btn-sm" @click="openEditProduct(p)">✏️</button>
|
||||||
|
<button class="btn-sm btn-danger" @click="removeProduct(p.id)">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── MESSAGES SECTION ──────────────────────────── -->
|
||||||
|
<section v-if="shop" class="seller-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h3>Nachrichten <span class="count-badge">{{ conversations.length }}</span></h3>
|
||||||
|
<button v-if="activeConv" class="btn-sm" @click="activeConv = null">← Zurück</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conversation list -->
|
||||||
|
<template v-if="!activeConv">
|
||||||
|
<div v-if="conversations.length === 0" class="empty-hint">Noch keine Nachrichten von Käufern.</div>
|
||||||
|
<div v-for="c in conversations" :key="`${c.product_id}_${c.buyer_id}`"
|
||||||
|
class="conv-row" @click="openConversation(c)">
|
||||||
|
<div class="conv-info">
|
||||||
|
<strong>{{ c.buyer_name }}</strong>
|
||||||
|
<div class="meta">{{ c.product_title }}</div>
|
||||||
|
<div class="conv-preview">{{ c.last_message }}</div>
|
||||||
|
</div>
|
||||||
|
<span style="color:#555;font-size:20px">›</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Thread view -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="thread-header">
|
||||||
|
<strong>{{ activeConv.buyer_name }}</strong>
|
||||||
|
<div class="meta">{{ activeConv.product_title }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="threadLoading" style="color:#aaa;padding:12px">Lädt...</div>
|
||||||
|
<div v-else class="thread-messages" ref="threadEl">
|
||||||
|
<div v-if="threadMessages.length === 0" class="empty-hint">Keine Nachrichten.</div>
|
||||||
|
<div v-for="m in threadMessages" :key="m.id"
|
||||||
|
:class="['bubble', m.sender === 'buyer' ? 'bubble-buyer' : 'bubble-seller']">
|
||||||
|
<span>{{ m.body }}</span>
|
||||||
|
<small>{{ new Date(m.created_at).toLocaleString('de-DE') }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px">
|
||||||
|
<textarea v-model="replyDraft" rows="2" placeholder="Antwort schreiben…" class="reply-input"
|
||||||
|
@keydown.enter.exact.prevent="sendReply" />
|
||||||
|
<button class="btn-primary" @click="sendReply"
|
||||||
|
:disabled="replySending || !replyDraft.trim()"
|
||||||
|
style="align-self:flex-end;padding:10px 16px">
|
||||||
|
{{ replySending ? '…' : '➤' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small style="color:#555;font-size:11px">Enter zum Senden</small>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuth } from '@/useAuth.js'
|
||||||
|
import {
|
||||||
|
getSellerShop, saveSellerShop,
|
||||||
|
addSellerProduct, updateSellerProduct, deleteSellerProduct,
|
||||||
|
getSellerMessages,
|
||||||
|
} from '@/api.js'
|
||||||
|
import { fetchMessages, sendMessage } from '@/api.js'
|
||||||
|
|
||||||
|
const CATEGORIES = ['Baumaterial', 'Werkzeug', 'Holz & Platten', 'Farben & Lacke', 'Sanitär', 'Elektro']
|
||||||
|
const CACHE_KEY = 'dilomarkt_seller_shop'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const loading = ref(true) // true by default — prevents setup-form flash
|
||||||
|
const saving = ref(false)
|
||||||
|
const pageMsg = ref('')
|
||||||
|
const msgIsError = ref(false)
|
||||||
|
const shop = ref(null)
|
||||||
|
const products = ref([])
|
||||||
|
|
||||||
|
// Hydrate from cache synchronously before first render — eliminates flash on repeat visits
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CACHE_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const c = JSON.parse(raw)
|
||||||
|
shop.value = c.shop ?? null
|
||||||
|
products.value = c.products ?? []
|
||||||
|
loading.value = false // cached data is ready — no spinner needed
|
||||||
|
}
|
||||||
|
} catch { localStorage.removeItem(CACHE_KEY) }
|
||||||
|
|
||||||
|
const showShopForm = ref(false)
|
||||||
|
const shopForm = ref({ name: '', type: 'Fachhandel', address: '', city: '', zip: '' })
|
||||||
|
|
||||||
|
const showProductForm = ref(false)
|
||||||
|
const editingProductId = ref(null)
|
||||||
|
const productForm = ref({ title: '', price: '', category: 'Baumaterial', stock: 1, description: '', icon: '📦' })
|
||||||
|
|
||||||
|
// ── Messages ─────────────────────────────────────────────────────────────────
|
||||||
|
const conversations = ref([])
|
||||||
|
const activeConv = ref(null)
|
||||||
|
const threadMessages = ref([])
|
||||||
|
const replyDraft = ref('')
|
||||||
|
const threadLoading = ref(false)
|
||||||
|
const replySending = ref(false)
|
||||||
|
const threadEl = ref(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!user.value || user.value.role !== 'seller') { router.push('/'); return }
|
||||||
|
await loadShop()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadShop() {
|
||||||
|
if (!shop.value) loading.value = true // only spin if nothing to show yet
|
||||||
|
try {
|
||||||
|
const data = await getSellerShop()
|
||||||
|
shop.value = data.shop
|
||||||
|
products.value = data.products
|
||||||
|
if (data.shop) populateShopForm(data.shop)
|
||||||
|
// Persist to cache so next visit is instant
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify({ shop: data.shop, products: data.products }))
|
||||||
|
if (data.shop) await loadConversations()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateShopForm(s) {
|
||||||
|
shopForm.value = { name: s.name, type: s.type, address: s.address, city: s.city, zip: s.zip }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitShop() {
|
||||||
|
saving.value = true; pageMsg.value = ''
|
||||||
|
try {
|
||||||
|
const data = await saveSellerShop(shopForm.value)
|
||||||
|
shop.value = data.shop
|
||||||
|
showShopForm.value = false
|
||||||
|
showMsg('Shop gespeichert.', false)
|
||||||
|
} catch (e) { showMsg(e.message, true) }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddProduct() {
|
||||||
|
editingProductId.value = null
|
||||||
|
productForm.value = { title: '', price: '', category: 'Baumaterial', stock: 1, description: '', icon: '📦' }
|
||||||
|
showProductForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditProduct(p) {
|
||||||
|
editingProductId.value = p.id
|
||||||
|
productForm.value = { title: p.title, price: p.price, category: p.category, stock: p.stock, description: p.description, icon: p.icon }
|
||||||
|
showProductForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProductForm() {
|
||||||
|
showProductForm.value = false
|
||||||
|
editingProductId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitProduct() {
|
||||||
|
saving.value = true; pageMsg.value = ''
|
||||||
|
const isEdit = !!editingProductId.value
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
const data = await updateSellerProduct(editingProductId.value, productForm.value)
|
||||||
|
const idx = products.value.findIndex(p => p.id === editingProductId.value)
|
||||||
|
if (idx !== -1) products.value[idx] = data.product
|
||||||
|
} else {
|
||||||
|
const data = await addSellerProduct(productForm.value)
|
||||||
|
products.value.push(data.product)
|
||||||
|
}
|
||||||
|
closeProductForm()
|
||||||
|
showMsg(isEdit ? 'Produkt aktualisiert.' : 'Produkt hinzugefügt.', false)
|
||||||
|
} catch (e) { showMsg(e.message, true) }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProduct(id) {
|
||||||
|
if (!confirm('Dieses Produkt wirklich löschen?')) return
|
||||||
|
try {
|
||||||
|
await deleteSellerProduct(id)
|
||||||
|
products.value = products.value.filter(p => p.id !== id)
|
||||||
|
showMsg('Produkt gelöscht.', false)
|
||||||
|
} catch (e) { showMsg(e.message, true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConversations() {
|
||||||
|
try {
|
||||||
|
const data = await getSellerMessages()
|
||||||
|
conversations.value = data.conversations
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConversation(c) {
|
||||||
|
activeConv.value = c
|
||||||
|
replyDraft.value = ''
|
||||||
|
threadLoading.value = true
|
||||||
|
try {
|
||||||
|
threadMessages.value = await fetchMessages(c.product_id, c.buyer_id)
|
||||||
|
await nextTick()
|
||||||
|
if (threadEl.value) threadEl.value.scrollTop = threadEl.value.scrollHeight
|
||||||
|
} finally {
|
||||||
|
threadLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply() {
|
||||||
|
if (!replyDraft.value.trim() || replySending.value) return
|
||||||
|
replySending.value = true
|
||||||
|
try {
|
||||||
|
await sendMessage({
|
||||||
|
product_id: activeConv.value.product_id,
|
||||||
|
provider_id: shop.value.id,
|
||||||
|
buyer_id: activeConv.value.buyer_id,
|
||||||
|
sender: 'seller',
|
||||||
|
body: replyDraft.value.trim(),
|
||||||
|
})
|
||||||
|
replyDraft.value = ''
|
||||||
|
threadMessages.value = await fetchMessages(activeConv.value.product_id, activeConv.value.buyer_id)
|
||||||
|
await nextTick()
|
||||||
|
if (threadEl.value) threadEl.value.scrollTop = threadEl.value.scrollHeight
|
||||||
|
} finally {
|
||||||
|
replySending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMsg(text, isError) {
|
||||||
|
pageMsg.value = text
|
||||||
|
msgIsError.value = isError
|
||||||
|
setTimeout(() => { pageMsg.value = '' }, 4000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.seller-section { background: var(--bg-dark-card); border: 1px solid var(--border-color); border-radius: 10px; padding: 20px; margin-bottom: 20px; }
|
||||||
|
.section-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.section-head h3 { margin: 0; font-size: 15px; letter-spacing: 0.5px; }
|
||||||
|
|
||||||
|
.shop-info-card { display: flex; gap: 16px; align-items: center; }
|
||||||
|
.shop-initials { width: 52px; height: 52px; border-radius: 50%; background: var(--primary-accent); display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: bold; flex-shrink: 0; }
|
||||||
|
.meta { font-size: 13px; color: #888; margin-top: 4px; }
|
||||||
|
.badge-ok { background: #1b5e20; color: #a5d6a7; font-size: 12px; padding: 2px 10px; border-radius: 20px; }
|
||||||
|
.badge-pending { background: #333; color: #aaa; font-size: 12px; padding: 2px 10px; border-radius: 20px; }
|
||||||
|
|
||||||
|
.seller-form { margin-top: 8px; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; }
|
||||||
|
.field-block { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field-block label { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 0.6px; }
|
||||||
|
.field-block input, .field-block select, .field-block textarea {
|
||||||
|
background: #2a2a2a; border: 1px solid var(--border-color); border-radius: 6px;
|
||||||
|
color: var(--text-main); padding: 9px 12px; font-size: 14px;
|
||||||
|
}
|
||||||
|
.field-block textarea { resize: vertical; font-family: inherit; }
|
||||||
|
.form-actions { display: flex; gap: 10px; margin-top: 18px; }
|
||||||
|
.btn-sm { background: #2a2a2a; border: 1px solid var(--border-color); color: var(--text-main); padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||||
|
.btn-sm:hover { border-color: var(--primary-accent); }
|
||||||
|
.btn-cancel { background: transparent; border: 1px solid var(--border-color); color: #888; padding: 10px 18px; border-radius: 6px; cursor: pointer; }
|
||||||
|
.btn-danger { border-color: #b71c1c !important; color: #ef9a9a !important; }
|
||||||
|
|
||||||
|
.product-form { background: #1a1a1a; border: 1px solid var(--border-color); border-radius: 8px; padding: 18px; margin-bottom: 16px; }
|
||||||
|
.product-row { display: flex; align-items: center; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--border-color); }
|
||||||
|
.product-row:last-child { border-bottom: none; }
|
||||||
|
.product-icon-cell { font-size: 28px; width: 36px; text-align: center; flex-shrink: 0; }
|
||||||
|
.product-info { flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.row-actions { display: flex; gap: 8px; }
|
||||||
|
.count-badge { background: #2a2a2a; border: 1px solid var(--border-color); border-radius: 20px; padding: 1px 10px; font-size: 12px; font-weight: normal; }
|
||||||
|
.empty-hint { color: #666; padding: 20px 0; text-align: center; }
|
||||||
|
|
||||||
|
.msg-ok { color: #81c784; padding: 10px 14px; background: #1b5e20; border-radius: 6px; margin-bottom: 12px; }
|
||||||
|
.msg-error { color: #ef9a9a; padding: 10px 14px; background: #b71c1c33; border-radius: 6px; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.conv-row { display: flex; align-items: center; gap: 12px; padding: 12px 4px; border-bottom: 1px solid var(--border-color); cursor: pointer; }
|
||||||
|
.conv-row:last-child { border-bottom: none; }
|
||||||
|
.conv-row:hover { background: #222; border-radius: 6px; }
|
||||||
|
.conv-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.conv-preview { font-size: 13px; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 400px; }
|
||||||
|
|
||||||
|
.thread-header { padding: 10px 0 14px; border-bottom: 1px solid var(--border-color); margin-bottom: 12px; }
|
||||||
|
.thread-messages { display: flex; flex-direction: column; gap: 8px; max-height: 320px; overflow-y: auto; padding: 4px 0; }
|
||||||
|
.bubble { display: flex; flex-direction: column; max-width: 75%; padding: 8px 12px; border-radius: 10px; font-size: 14px; }
|
||||||
|
.bubble small { font-size: 10px; color: #666; margin-top: 4px; }
|
||||||
|
.bubble-buyer { align-self: flex-start; background: #2a2a2a; border: 1px solid var(--border-color); }
|
||||||
|
.bubble-seller { align-self: flex-end; background: #1565c0; }
|
||||||
|
.reply-input { flex: 1; background: #2a2a2a; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-main); padding: 8px 12px; font-size: 14px; resize: none; font-family: inherit; }
|
||||||
|
</style>
|
||||||
36
Dilomarkt/src/views/VerifyView.vue
Normal file
36
Dilomarkt/src/views/VerifyView.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<template>
|
||||||
|
<div class="view-container auth-shell">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h2>Account verifizieren</h2>
|
||||||
|
<p class="auth-subtitle">Gib den 6-stelligen Code aus deiner E-Mail ein.</p>
|
||||||
|
<form @submit.prevent="submitVerify" class="auth-form">
|
||||||
|
<input v-model="code" maxlength="6" placeholder="6-stelliger Code" required />
|
||||||
|
<button class="btn-primary auth-submit" type="submit">Verifizieren</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="message" class="status-message">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { verifyUser } from '@/api.js'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const code = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
|
||||||
|
async function submitVerify() {
|
||||||
|
try {
|
||||||
|
const data = await verifyUser({ email: route.query.email, code: code.value })
|
||||||
|
message.value = data.message
|
||||||
|
if (data.status === 'verified') {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"extends": "./node_modules/@vue/tsconfig/tsconfig.dom.json",
|
||||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
"exclude": ["src/**/__tests__/*"],
|
"exclude": ["src/**/__tests__/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue