Authentication & mini fixes

This commit is contained in:
Raja Ferzly 2026-06-30 11:54:45 +02:00
parent 86273e1765
commit 93fe6fa5ca
31 changed files with 1726 additions and 84 deletions

Binary file not shown.

View file

@ -57,3 +57,33 @@ ## Security Vulnerabilities
## License
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.

View 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));
}
}

View file

@ -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]);
}
}

View file

@ -19,9 +19,15 @@ class User extends Authenticatable
* @var list<string>
*/
protected $fillable = [
'first_name',
'last_name',
'name',
'email',
'password',
'role',
'verification_code',
'password_reset_token',
'api_token',
];
/**
@ -46,4 +52,9 @@ protected function casts(): array
'password' => 'hashed',
];
}
public function hasVerifiedEmail(): bool
{
return ! is_null($this->email_verified_at);
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -2,7 +2,7 @@
return [
'paths' => ['api/*'],
'allowed_methods' => ['*'],
'allowed_origins' => ['http://localhost:5173'],
'allowed_origins' => ['http://localhost:5173', 'http://localhost:5174'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],

View file

@ -13,10 +13,15 @@ public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('first_name');
$table->string('last_name');
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('role')->default('buyer');
$table->string('verification_code')->nullable();
$table->string('password_reset_token')->nullable();
$table->rememberToken();
$table->timestamps();
});

View file

@ -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');
});
}
};

View file

@ -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');
});
}
};

View file

@ -1,8 +1,16 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\ProviderController;
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/{id}', [ProductController::class, 'show']);
@ -10,3 +18,11 @@
Route::get('/providers/{id}', [ProviderController::class, 'show']);
Route::get('/messages', [MessageController::class, 'index']);
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']);

View 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";
}

View 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);
}
}

View file

@ -57,6 +57,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@ -481,33 +482,10 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
@ -689,9 +667,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -709,9 +684,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -729,9 +701,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -749,9 +718,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -769,9 +735,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -789,9 +752,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -837,6 +797,40 @@
"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": {
"version": "1.0.2",
"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==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -1330,6 +1325,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@ -1875,9 +1871,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -1899,9 +1892,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -1923,9 +1913,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -1947,9 +1934,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2513,6 +2497,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -2601,6 +2586,7 @@
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@ -2789,6 +2775,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz",
"integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.35",
"@vue/compiler-sfc": "3.5.35",

View file

@ -1,5 +1,32 @@
<template>
<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 />
<nav class="mobile-bottom-nav">
@ -10,9 +37,47 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuth } from '@/useAuth.js'
const route = useRoute()
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>
<style>
@ -84,4 +149,49 @@ body {
.products-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
.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>

View file

@ -1,5 +1,32 @@
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 = {}) {
const q = new URLSearchParams(params).toString()
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')
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)

View file

@ -3,6 +3,14 @@ import HomeView from '@/views/HomeView.vue'
import SearchView from '@/views/SearchView.vue'
import ProductView from '@/views/ProductView.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({
history: createWebHistory(import.meta.env.BASE_URL),
@ -12,6 +20,14 @@ const router = createRouter({
{ path: '/suche', name: 'search', component: SearchView },
{ path: '/produkt/:id', name: 'product', component: ProductView },
{ 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
View 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 }
}

View file

@ -36,6 +36,7 @@
<script setup>
import { ref, watch, nextTick } from 'vue'
import { fetchMessages, sendMessage } from '@/api.js'
import { useAuth } from '@/useAuth.js'
const props = defineProps({
open: Boolean,
@ -45,8 +46,8 @@ const props = defineProps({
})
defineEmits(['close'])
// Hardcoded buyer_id=1 bis Auth fertig ist
const BUYER_ID = 1
const { user } = useAuth()
const buyerId = () => user.value?.id ?? null
const messages = ref([])
const draft = ref('')
@ -58,9 +59,10 @@ const listEl = ref(null)
let pollInterval = null
async function loadMessages() {
if (!buyerId()) return
loading.value = true
try {
messages.value = await fetchMessages(props.productId, BUYER_ID)
messages.value = await fetchMessages(props.productId, buyerId())
await nextTick()
if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight
} catch (e) {
@ -78,7 +80,7 @@ async function send() {
await sendMessage({
product_id: props.productId,
provider_id: props.providerId,
buyer_id: BUYER_ID,
buyer_id: buyerId(),
sender: 'buyer',
body: draft.value.trim(),
})

View 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>

View file

@ -51,7 +51,7 @@
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { fetchProducts } from '@/api.js'
@ -62,15 +62,24 @@ const plz = ref('42103')
const cat = ref('')
const radius = ref(10)
const types = ref([])
const products = ref([])
const allProducts = ref([])
const loading = ref(false)
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() {
loading.value = true
error.value = ''
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) {
error.value = e.message
} finally {
@ -78,7 +87,6 @@ async function load() {
}
}
watch([radius, cat], load)
onMounted(load)
function doSearch() {

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -2,28 +2,28 @@
<div class="view-container">
<div class="search-container" style="max-width:100%;margin-bottom:16px">
<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 v-for="c in CATEGORIES" :key="c">{{ c }}</option>
</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>
</div>
<div class="category-chips">
<span :class="['chip', !cat && 'active']" @click="cat='';load()">Alle</span>
<span v-for="c in CATEGORIES" :key="c" :class="['chip', cat===c && 'active']" @click="cat=c;load()">{{ c }}</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">{{ c }}</span>
</div>
<div class="marketplace-body">
<aside class="filter-sidebar">
<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>
<label><input type="checkbox" v-model="types" value="Fachhandel" @change="load" /> Fachhandel</label>
<label><input type="checkbox" v-model="types" value="Baumarkt" @change="load" /> Baumarkt</label>
<label><input type="checkbox" v-model="types" value="Fachhandel" /> Fachhandel</label>
<label><input type="checkbox" v-model="types" value="Baumarkt" /> Baumarkt</label>
<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>
</aside>
<main style="flex:1">
@ -55,7 +55,7 @@
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchProducts } from '@/api.js'
@ -69,17 +69,33 @@ const plz = ref(route.query.plz ?? '42103')
const radius = ref(20)
const maxPrice = ref(500)
const types = ref([])
const products = ref([])
const allProducts = ref([])
const loading = ref(false)
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() {
loading.value = true
error.value = ''
try {
const params = { plz: plz.value, radius: radius.value, q: q.value, category: cat.value, max_price: maxPrice.value }
if (types.value.length === 1) params.type = types.value[0]
products.value = await fetchProducts(params)
// Fetch broad dataset once; client-side computed handles the rest
allProducts.value = await fetchProducts({ plz: plz.value, radius: 50 })
router.replace({ query: { q: q.value, cat: cat.value, plz: plz.value } })
} catch (e) {
error.value = e.message
@ -88,10 +104,10 @@ async function load() {
}
}
watch(() => route.query, q => {
q.value = route.query.q ?? ''
cat.value = route.query.cat ?? ''
load()
// Sync q/cat from route params (no reload computed handles it)
watch(() => route.query, (newQ) => {
q.value = newQ.q ?? ''
cat.value = newQ.cat ?? ''
})
onMounted(load)

View 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>

View 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>

View file

@ -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"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {