Compare commits
No commits in common. "2cbabf05542fc77168a626aee29e1dc8be1ed80e" and "d4ecbe5b45aa8896d4ac02bdd4757c6758b5b98e" have entirely different histories.
2cbabf0554
...
d4ecbe5b45
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ContactInfoResource;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HeroSection;
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\LoginRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle user login
|
||||
*/
|
||||
public function login(LoginRequest $request)
|
||||
{
|
||||
$credentials = $request->validated();
|
||||
|
||||
// Auto-assign role based on email domain
|
||||
$email = $credentials['email'];
|
||||
$role = $this->assignRoleByEmail($email);
|
||||
|
||||
// Find user by email
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if (!$user || !Hash::check($credentials['password'], $user->password)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Email atau password salah'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// Update user role if changed
|
||||
if ($user->role !== $role) {
|
||||
$user->role = $role;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// Create token
|
||||
$token = $user->createToken('auth-token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Login berhasil',
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => $user->role,
|
||||
'nim' => $user->nim,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user logout
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Logout berhasil'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
public function user(Request $request)
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'user' => [
|
||||
'id' => $request->user()->id,
|
||||
'name' => $request->user()->name,
|
||||
'email' => $request->user()->email,
|
||||
'role' => $request->user()->role,
|
||||
'nim' => $request->user()->nim,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-assign role based on email domain
|
||||
*/
|
||||
private function assignRoleByEmail(string $email): string
|
||||
{
|
||||
// Student email pattern: nim@student.polije.ac.id
|
||||
if (str_contains($email, 'student.polije.ac.id')) {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
// Staff email pattern: name@polije.ac.id
|
||||
if (str_contains($email, 'polije.ac.id')) {
|
||||
return 'konselor'; // Default to konselor for staff
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 'user';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RoleMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $role): Response
|
||||
{
|
||||
if (!auth()->check()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Unauthorized'
|
||||
], 401);
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Check if user has the required role
|
||||
if ($user->role !== $role) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Forbidden - Insufficient permissions'
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|min:8',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the error messages for the defined validation rules.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'email.required' => 'Email wajib diisi',
|
||||
'email.email' => 'Format email tidak valid',
|
||||
'password.required' => 'Password wajib diisi',
|
||||
'password.min' => 'Password minimal 8 karakter',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*
|
||||
* @param \Illuminate\Validation\Validator $validator
|
||||
* @return void
|
||||
*/
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$email = $this->input('email');
|
||||
|
||||
// Custom validation: email must contain polije.ac.id
|
||||
if ($email && !str_contains($email, 'polije.ac.id')) {
|
||||
$validator->errors()->add('email', 'Gunakan email resmi Polije (@polije.ac.id atau @student.polije.ac.id)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom attributes for validator errors.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'email' => 'Email',
|
||||
'password' => 'Password',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -6,12 +6,11 @@
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, HasApiTokens;
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
@ -22,8 +21,6 @@ class User extends Authenticatable
|
|||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
'nim',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -48,28 +45,4 @@ protected function casts(): array
|
|||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is student
|
||||
*/
|
||||
public function isStudent(): bool
|
||||
{
|
||||
return $this->role === 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is konselor
|
||||
*/
|
||||
public function isKonselor(): bool
|
||||
{
|
||||
return $this->role === 'konselor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is operator
|
||||
*/
|
||||
public function isOperator(): bool
|
||||
{
|
||||
return $this->role === 'operator';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
},
|
||||
"require-dev": {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "ad91ab9bde70e3576f2b64dd11542bc4",
|
||||
"content-hash": "c514d8f7b9fc5970bdd94287905ef584",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
|
|
@ -1333,69 +1333,6 @@
|
|||
},
|
||||
"time": "2026-02-06T12:17:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sanctum",
|
||||
"version": "v4.3.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/sanctum.git",
|
||||
"reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
|
||||
"reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
||||
"illuminate/database": "^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||
"php": "^8.2",
|
||||
"symfony/console": "^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
||||
"phpstan/phpstan": "^1.10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Sanctum\\SanctumServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Sanctum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"laravel",
|
||||
"sanctum"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/sanctum/issues",
|
||||
"source": "https://github.com/laravel/sanctum"
|
||||
},
|
||||
"time": "2026-02-07T17:19:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v2.0.9",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:5175', 'http://localhost:5176', 'http://127.0.0.1:5176'],
|
||||
'allowed_origins' => ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:5175'],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort(),
|
||||
// Sanctum::currentRequestHost(),
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
||||
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
|
||||
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -17,11 +17,24 @@ public function up(): void
|
|||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->enum('role', ['user', 'konselor', 'operator'])->default('user');
|
||||
$table->string('nim')->nullable(); // For student users
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -30,5 +43,7 @@ public function up(): void
|
|||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('tokenable');
|
||||
$table->text('name');
|
||||
$table->string('token', 64)->unique();
|
||||
$table->text('abilities')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('personal_access_tokens');
|
||||
}
|
||||
};
|
||||
|
|
@ -79,33 +79,10 @@ public function run(): void
|
|||
Article::create($article);
|
||||
}
|
||||
|
||||
// Create test users with specific roles
|
||||
$users = [
|
||||
[
|
||||
'name' => 'Ahmad Mahasiswa',
|
||||
'email' => '2021001@student.polije.ac.id',
|
||||
'password' => Hash::make('password123'),
|
||||
'role' => 'user',
|
||||
'nim' => '2021001',
|
||||
],
|
||||
[
|
||||
'name' => 'Siti Konselor',
|
||||
'email' => 'siti@polije.ac.id',
|
||||
'password' => Hash::make('password123'),
|
||||
'role' => 'konselor',
|
||||
'nim' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'Budi Operator',
|
||||
'email' => 'budi@polije.ac.id',
|
||||
'password' => Hash::make('password123'),
|
||||
'role' => 'operator',
|
||||
'nim' => null,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($users as $user) {
|
||||
User::create($user);
|
||||
}
|
||||
// Create test user
|
||||
User::factory()->create([
|
||||
'name' => 'Admin PolijeCare',
|
||||
'email' => 'admin@polije.ac.id',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UserSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Create test users
|
||||
$users = [
|
||||
[
|
||||
'name' => 'Ahmad Mahasiswa',
|
||||
'email' => '2021001@student.polije.ac.id',
|
||||
'password' => Hash::make('password123'),
|
||||
'role' => 'user',
|
||||
'nim' => '2021001',
|
||||
],
|
||||
[
|
||||
'name' => 'Siti Konselor',
|
||||
'email' => 'siti@polije.ac.id',
|
||||
'password' => Hash::make('password123'),
|
||||
'role' => 'konselor',
|
||||
'nim' => null,
|
||||
],
|
||||
[
|
||||
'name' => 'Budi Operator',
|
||||
'email' => 'budi@polije.ac.id',
|
||||
'password' => Hash::make('password123'),
|
||||
'role' => 'operator',
|
||||
'nim' => null,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($users as $user) {
|
||||
User::create($user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,9 @@
|
|||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\API\ArticleController;
|
||||
use App\Http\Controllers\API\ContactController;
|
||||
use App\Http\Controllers\API\HeroController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Middleware\RoleMiddleware;
|
||||
use App\Http\Controllers\Api\ArticleController;
|
||||
use App\Http\Controllers\Api\ContactController;
|
||||
use App\Http\Controllers\Api\HeroController;
|
||||
|
||||
Route::get('/test', function () {
|
||||
return response()->json([
|
||||
|
|
@ -23,54 +21,3 @@
|
|||
|
||||
// Hero Section
|
||||
Route::get('/hero', [HeroController::class, 'index']);
|
||||
|
||||
// Auth routes
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
|
||||
// Protected routes
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::post('/logout', [AuthController::class, 'logout']);
|
||||
Route::get('/user', [AuthController::class, 'user']);
|
||||
|
||||
// User routes
|
||||
Route::middleware(RoleMiddleware::class . ':user')->prefix('user')->group(function () {
|
||||
Route::get('/dashboard', function () {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'User Dashboard',
|
||||
'data' => [
|
||||
'role' => 'user',
|
||||
'dashboard' => 'student'
|
||||
]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Konselor routes
|
||||
Route::middleware(RoleMiddleware::class . ':konselor')->prefix('konselor')->group(function () {
|
||||
Route::get('/dashboard', function () {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Konselor Dashboard',
|
||||
'data' => [
|
||||
'role' => 'konselor',
|
||||
'dashboard' => 'konselor'
|
||||
]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Operator routes
|
||||
Route::middleware(RoleMiddleware::class . ':operator')->prefix('operator')->group(function () {
|
||||
Route::get('/dashboard', function () {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Operator Dashboard',
|
||||
'data' => [
|
||||
'role' => 'operator',
|
||||
'dashboard' => 'operator'
|
||||
]
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
|
||||
$kernel->bootstrap();
|
||||
|
||||
echo "Backend server is ready!\n";
|
||||
echo "Available routes:\n";
|
||||
echo "- GET /api/test\n";
|
||||
echo "- POST /api/login\n";
|
||||
echo "- GET /api/user\n";
|
||||
echo "- GET /api/contact\n";
|
||||
echo "- GET /api/hero\n";
|
||||
echo "- GET /api/articles\n";
|
||||
|
||||
// Test database connection
|
||||
try {
|
||||
\Illuminate\Support\Facades\DB::connection()->getPdo();
|
||||
echo "\n✅ Database connection: OK\n";
|
||||
|
||||
// Check users
|
||||
$userCount = \App\Models\User::count();
|
||||
echo "✅ Users in database: {$userCount}\n";
|
||||
|
||||
if ($userCount > 0) {
|
||||
echo "✅ Test users ready for login\n";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
echo "\n❌ Database connection failed: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "\nTo start the server, run: php artisan serve\n";
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@react-bits": "https://reactbits.dev/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,27 +10,12 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"axios": "^1.13.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^10.18.0",
|
||||
"lucide-react": "^0.564.0",
|
||||
"framer-motion": "^10.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"styled-components": "^6.3.9",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 576 KiB |
|
Before Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 1.2 MiB |
|
|
@ -1,55 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
const AppMinimal = () => {
|
||||
return (
|
||||
<div style={{ padding: '20px', fontFamily: 'Arial, sans-serif' }}>
|
||||
<h1 style={{ color: '#E6E6FA', marginBottom: '20px' }}>PolijeCare - Test Page</h1>
|
||||
<p style={{ color: '#666', marginBottom: '20px' }}>Halaman test untuk memastikan React berfungsi.</p>
|
||||
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<h2 style={{ color: '#333', marginBottom: '10px' }}>Status System:</h2>
|
||||
<ul style={{ color: '#666', lineHeight: '1.6' }}>
|
||||
<li>✅ React berjalan</li>
|
||||
<li>✅ CSS terload</li>
|
||||
<li>✅ Tidak ada black screen</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#E6E6FA',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<h2 style={{ color: '#333', marginBottom: '10px' }}>Test Login:</h2>
|
||||
<p style={{ color: '#666', marginBottom: '10px' }}>Akun test tersedia:</p>
|
||||
<div style={{ backgroundColor: '#fff', padding: '10px', borderRadius: '4px' }}>
|
||||
<strong>Mahasiswa:</strong> 2021001@student.polije.ac.id / password123<br/>
|
||||
<strong>Konselor:</strong> siti@polije.ac.id / password123<br/>
|
||||
<strong>Operator:</strong> budi@polije.ac.id / password123
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => alert('Test button berfungsi!')}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#4C6EF5',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Test Button
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppMinimal;
|
||||
|
|
@ -1,87 +1,29 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
import { ScrollParticles } from './components/ui/scroll-particles';
|
||||
|
||||
// Pages
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import UserDashboard from './pages/UserDashboard';
|
||||
import KonselorDashboard from './pages/KonselorDashboard';
|
||||
import OperatorDashboard from './pages/OperatorDashboard';
|
||||
import ArticleDetail from './components/ArticleDetail';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ScrollParticles />
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Router>
|
||||
<div className="App">
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/about" element={<LandingPage />} />
|
||||
<Route path="/services" element={<LandingPage />} />
|
||||
<Route path="/articles" element={<LandingPage />} />
|
||||
<Route path="/contact" element={<LandingPage />} />
|
||||
|
||||
{/* Protected Dashboard Routes */}
|
||||
<Route
|
||||
path="/user/dashboard"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="user">
|
||||
<UserDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/konselor/dashboard"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="konselor">
|
||||
<KonselorDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/operator/dashboard"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="operator">
|
||||
<OperatorDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Redirect */}
|
||||
<Route path="/redirect" element={<RedirectDashboard />} />
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="/artikel" element={<LandingPage />} />
|
||||
<Route path="/artikel/:slug" element={<ArticleDetail />} />
|
||||
<Route path="/login" element={<LandingPage />} />
|
||||
<Route path="/user/dashboard" element={<LandingPage />} />
|
||||
<Route path="/operator/dashboard" element={<LandingPage />} />
|
||||
<Route path="/konselor/dashboard" element={<LandingPage />} />
|
||||
<Route path="/redirect" element={<LandingPage />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</div>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect component for role-based navigation
|
||||
const RedirectDashboard = () => {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
switch (user.role) {
|
||||
case 'user':
|
||||
return <Navigate to="/user/dashboard" replace />;
|
||||
case 'konselor':
|
||||
return <Navigate to="/konselor/dashboard" replace />;
|
||||
case 'operator':
|
||||
return <Navigate to="/operator/dashboard" replace />;
|
||||
default:
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,239 @@
|
|||
import AboutSection from "@/components/ui/about-section";
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { fadeIn, slideUp, slideLeft, staggerChildren } from '../utils/motionVariants';
|
||||
|
||||
export default function About() {
|
||||
return <AboutSection />;
|
||||
}
|
||||
const About = () => {
|
||||
const features = [
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
|
||||
</svg>
|
||||
),
|
||||
title: 'Perlindungan',
|
||||
description: 'Melindungi korban dan saksi dari segala bentuk ancaman atau intimidasi'
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
),
|
||||
title: 'Pendampingan',
|
||||
description: 'Memberikan dukungan psikologis dan hukum yang dibutuhkan'
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
|
||||
</svg>
|
||||
),
|
||||
title: 'Kerahasiaan',
|
||||
description: 'Menjaga identitas dan informasi pelapor dengan ketat'
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
),
|
||||
title: 'Keadilan',
|
||||
description: 'Memastikan proses yang adil dan transparan untuk semua pihak'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="about" className="py-20 bg-gradient-to-br from-purple-50 to-purple-100 relative overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute top-10 right-10 w-64 h-64 bg-primary rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-10 left-10 w-96 h-96 bg-accent rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
className="text-center mb-20"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<motion.h2
|
||||
className="text-4xl md:text-5xl font-bold text-gray-900 mb-6"
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
Tentang <span className="text-primary">Satgas PPKPT</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
className="text-xl text-gray-600 max-w-3xl mx-auto leading-relaxed"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
Satuan Tugas Pencegahan dan Penanganan Kekerasan Seksual Politeknik Negeri Jember
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||
{/* Left Content - Logo and Description */}
|
||||
<motion.div
|
||||
className="space-y-8"
|
||||
variants={slideLeft}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
{/* PolijeCare Logo */}
|
||||
<div className="flex justify-center lg:justify-start">
|
||||
<motion.div
|
||||
className="relative group"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="relative w-56 h-56">
|
||||
{/* Outer gradient ring */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary to-accent rounded-3xl flex items-center justify-center shadow-soft group-hover:shadow-card transition-all duration-300">
|
||||
<div className="w-48 h-48 bg-white rounded-2xl flex items-center justify-center">
|
||||
<div className="w-40 h-40 bg-gradient-to-br from-primary/10 to-accent/10 rounded-xl flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-primary">PolijeCare</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Floating decoration */}
|
||||
<motion.div
|
||||
className="absolute -top-3 -right-3 w-10 h-10 bg-accent rounded-2xl shadow-soft flex items-center justify-center"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
rotate: [0, 10, -10, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-gray-900">
|
||||
Layanan Pengaduan dan Pendampingan Terpercaya
|
||||
</h3>
|
||||
<p className="text-lg text-gray-600 leading-relaxed">
|
||||
PolijeCare merupakan kanal resmi pengaduan Satgas PPKPT Politeknik Negeri Jember yang menangani laporan kekerasan seksual secara empati, profesional, dan menjaga kerahasiaan.
|
||||
</p>
|
||||
<p className="text-lg text-gray-600 leading-relaxed">
|
||||
Kami berkomitmen untuk menciptakan lingkungan kampus yang <span className="text-primary font-semibold">aman</span>, <span className="text-primary font-semibold">mendukung</span>, dan <span className="text-primary font-semibold">bebas dari kekerasan seksual</span> bagi seluruh sivitas akademika.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
className="grid grid-cols-2 gap-6 pt-6"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<div className="text-center p-4 bg-white rounded-2xl shadow-soft">
|
||||
<div className="text-3xl font-bold text-primary mb-2">24/7</div>
|
||||
<div className="text-sm text-gray-600 font-medium">Layanan Darurat</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-white rounded-2xl shadow-soft">
|
||||
<div className="text-3xl font-bold text-accent mb-2">100%</div>
|
||||
<div className="text-sm text-gray-600 font-medium">Rahasia Terjamin</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Right Content - Features Grid */}
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-6"
|
||||
variants={staggerChildren}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
className="bg-white rounded-2xl p-6 shadow-soft hover:shadow-card transition-all duration-300 hover:-translate-y-1 group"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-primary/10 to-accent/10 rounded-xl flex items-center justify-center text-primary group-hover:from-primary/20 group-hover:to-accent/20 transition-all duration-300">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-2 group-hover:text-primary transition-colors">
|
||||
{feature.title}
|
||||
</h4>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Quote */}
|
||||
<motion.blockquote
|
||||
className="bg-gradient-to-r from-primary/10 to-accent/10 rounded-2xl p-6 border-l-4 border-primary"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<p className="text-lg text-gray-700 italic font-medium leading-relaxed">
|
||||
"Setiap individu berhak mendapatkan perlindungan dan rasa aman dalam menempuh pendidikan. Mari bersama-sama menjaga kampus kita sebagai tempat yang aman dan mendukung bagi semua."
|
||||
</p>
|
||||
<footer className="mt-4 text-sm text-gray-600 font-semibold">
|
||||
— Satgas PPKPT Polije
|
||||
</footer>
|
||||
</motion.blockquote>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { fadeIn, slideUp } from '../utils/motionVariants';
|
||||
import { Gallery4 } from './gallery4';
|
||||
import { articleService } from '../services/articleService';
|
||||
import { fadeIn, slideUp, staggerChildren } from '../utils/motionVariants';
|
||||
|
||||
const Articles = () => {
|
||||
const [articles, setArticles] = useState([]);
|
||||
|
|
@ -10,130 +10,229 @@ const Articles = () => {
|
|||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Temporary disable API calls to prevent CORS errors
|
||||
const mockArticles = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Guru Besar UGM Diduga Lakukan Kekerasan Seksual, Diberhentikan Sementara',
|
||||
slug: 'https://www.detik.com/jateng/berita/d-6204001/dugaan-kekerasan-seksual-guru-besar-ugm-dipecat-sebagai-dosen',
|
||||
image: 'https://images.unsplash.com/photo-1592280771800-45cb10bd3dcf?q=80&w=1740&auto=format&fit=crop',
|
||||
content: 'Universitas Gadjah Mada (UGM) mengambil tindakan tegas dengan memberhentikan sementara seorang guru besar yang diduga terlibat kasus kekerasan seksual.',
|
||||
is_published: true,
|
||||
published_at: '2025-01-20T00:00:00.000000Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Rektor Universitas Pancasila Nonaktif Jalani Pemeriksaan Kasus Pelecehan',
|
||||
slug: 'https://metro.tempo.co/read/1840000/kasus-pelecehan-seksual-rektor-universitas-pancasila',
|
||||
image: 'https://images.unsplash.com/photo-1541339907198-e08756dedf3f?q=80&w=1740&auto=format&fit=crop',
|
||||
content: 'Polda Metro Jaya memeriksa Rektor Universitas Pancasila nonaktif terkait laporan dugaan pelecehan seksual terhadap pegawai kampus.',
|
||||
is_published: true,
|
||||
published_at: '2024-06-15T00:00:00.000000Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Unand Resmikan Satgas PPK, Perluas Cakupan Penanganan Kekerasan',
|
||||
slug: 'https://www.unand.ac.id/id/berita-peristiwa/berita/item/5799-resmikan-satgas-ppk-rektor-unand-kawal-kampus-aman.html',
|
||||
image: 'https://images.unsplash.com/photo-1523050854058-8df90110c9f1?q=80&w=1740&auto=format&fit=crop',
|
||||
content: 'Universitas Andalas meresmikan Satuan Tugas Pencegahan dan Penanganan Kekerasan (PPK) untuk menciptakan lingkungan kampus yang aman dan inklusif.',
|
||||
is_published: true,
|
||||
published_at: '2024-11-10T00:00:00.000000Z'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Mahasiswa Unsri Tuntut Penuntasan Kasus Pelecehan Seksual',
|
||||
slug: 'https://www.cnnindonesia.com/nasional/20211203145209-12-729000/mahasiswa-unsri-demo-tuntut-usut-tuntas-dugaan-pelecehan-seksual',
|
||||
image: 'https://images.unsplash.com/photo-1555848960-8c3af5e4860c?q=80&w=1740&auto=format&fit=crop',
|
||||
content: 'Ratusan mahasiswa Universitas Sriwijaya menggelar aksi damai menuntut pengusutan tuntas kasus dugaan pelecehan seksual oleh oknum dosen.',
|
||||
is_published: true,
|
||||
published_at: '2024-10-05T00:00:00.000000Z'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Kemendikbudristek Cabut Izin Kampus yang Abaikan Kasus Kekerasan Seksual',
|
||||
slug: 'https://nasional.kompas.com/read/2023/06/07/11261391/izin-23-perguruan-tinggi-dicabut-ada-kampus-yang-abaikan-kasus-kekerasan',
|
||||
image: 'https://images.unsplash.com/photo-1450101499163-c8848c66ca85?q=80&w=1740&auto=format&fit=crop',
|
||||
content: 'Kemendikbudristek mengambil langkah tegas mencabut izin operasional perguruan tinggi yang terbukti melakukan pelanggaran berat, termasuk pembiaran kekerasan seksual.',
|
||||
is_published: true,
|
||||
published_at: '2024-02-01T00:00:00.000000Z'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Puan Maharani: Kampus Harus Jadi Ruang Aman Bebas Kekerasan Seksual',
|
||||
slug: 'https://www.dpr.go.id/berita/detail/id/35000/t/Ketua+DPR+Minta+Kampus+Jadi+Ruang+Aman+dari+Kekerasan+Seksual',
|
||||
image: 'https://images.unsplash.com/photo-1557804506-669a67965ba0?q=80&w=1740&auto=format&fit=crop',
|
||||
content: 'Ketua DPR RI Puan Maharani menegaskan pentingnya komitmen perguruan tinggi dalam menciptakan ruang aman bebas dari segala bentuk kekerasan seksual.',
|
||||
is_published: true,
|
||||
published_at: '2024-09-15T00:00:00.000000Z'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Komnas Perempuan: Kekerasan Seksual di Lingkungan Pendidikan Masih Mengkhawatirkan',
|
||||
slug: 'https://www.kompas.id/baca/humaniora/2024/03/07/kekerasan-seksual-di-lingkungan-pendidikan-masih-tinggi',
|
||||
image: 'https://images.unsplash.com/photo-1573164713988-8665fc963095?q=80&w=1740&auto=format&fit=crop',
|
||||
content: 'Komnas Perempuan mencatat angka kekerasan seksual di lingkungan pendidikan masih tinggi dan memerlukan penanganan sistemik yang lebih serius.',
|
||||
is_published: true,
|
||||
published_at: '2024-03-08T00:00:00.000000Z'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: 'Pentingnya Pendidikan Seksual di Kampus untuk Cegah Tindakan Asusila',
|
||||
slug: 'https://edukasi.kompas.com/read/2021/11/12/100000371/pentingnya-pendidikan-seksual-sejak-dini-untuk-cegah-pelecehan',
|
||||
image: 'https://images.unsplash.com/photo-1544531586-fde5298cdd40?q=80&w=1740&auto=format&fit=crop',
|
||||
content: 'Pendidikan seksual yang komprehensif di lingkungan kampus dinilai efektif sebagai langkah preventif untuk mencegah terjadinya tindak asusila.',
|
||||
is_published: true,
|
||||
published_at: '2024-01-10T00:00:00.000000Z'
|
||||
}
|
||||
];
|
||||
|
||||
setArticles(mockArticles);
|
||||
setLoading(false);
|
||||
fetchArticles();
|
||||
}, []);
|
||||
|
||||
const galleryItems = articles.map(article => ({
|
||||
id: article.id.toString(),
|
||||
title: article.title,
|
||||
description: article.content,
|
||||
href: article.slug,
|
||||
image: article.image
|
||||
}));
|
||||
const fetchArticles = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await articleService.getAll();
|
||||
if (response.success) {
|
||||
setArticles(response.data);
|
||||
} else {
|
||||
setError('Failed to load articles');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load articles');
|
||||
console.error('Error fetching articles:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const SectionTitle = (
|
||||
<span>
|
||||
Artikel & <span className="text-primary">Pengumuman</span>
|
||||
</span>
|
||||
);
|
||||
const formatDate = (dateString) => {
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
return new Date(dateString).toLocaleDateString('id-ID', options);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="articles" className="bg-gray-50 dark:bg-gray-900 min-h-screen relative overflow-hidden">
|
||||
{/* Background Decorations */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-primary/5 rounded-full blur-3xl translate-x-1/3 -translate-y-1/3"></div>
|
||||
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-3xl -translate-x-1/3 translate-y-1/3"></div>
|
||||
<section id="articles" className="py-20 bg-white relative overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute top-10 right-10 w-64 h-64 bg-primary rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-10 left-10 w-96 h-96 bg-accent rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
className="text-center mb-20"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<motion.h2
|
||||
className="text-4xl md:text-5xl font-bold text-gray-900 mb-6"
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
Artikel & <span className="text-primary">Pengumuman</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
className="text-xl text-gray-600 max-w-3xl mx-auto leading-relaxed"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
Dapatkan informasi terbaru seputar layanan, edukasi, dan pengumuman penting dari Satgas PPKPT Polije.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex justify-center items-center py-32">
|
||||
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<motion.div
|
||||
className="flex justify-center items-center py-20"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin"></div>
|
||||
<p className="text-gray-600 font-medium">Memuat artikel...</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="text-center py-32 text-red-500 max-w-lg mx-auto px-4">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<motion.div
|
||||
className="text-center py-20"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<div className="bg-danger/10 border border-danger/20 rounded-2xl p-8 max-w-md mx-auto">
|
||||
<div className="w-16 h-16 bg-danger/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-danger" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Gagal Memuat Artikel</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchArticles}
|
||||
className="px-6 py-2 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium"
|
||||
>
|
||||
Coba Lagi
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Articles Grid */}
|
||||
{!loading && !error && (
|
||||
<Gallery4
|
||||
title={SectionTitle}
|
||||
description="Dapatkan informasi terbaru seputar layanan, edukasi, dan pengumuman penting dari Satgas PPKPT Polije. Kami berkomitmen untuk transparansi dan edukasi."
|
||||
items={galleryItems}
|
||||
/>
|
||||
<>
|
||||
{articles.length === 0 ? (
|
||||
<motion.div
|
||||
className="text-center py-20"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<div className="bg-gray-50 rounded-2xl p-12 max-w-md mx-auto">
|
||||
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Belum Ada Artikel</h3>
|
||||
<p className="text-gray-600 text-sm">Belum ada artikel atau pengumuman yang tersedia saat ini.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
variants={staggerChildren}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
{articles.map((article, index) => (
|
||||
<motion.article
|
||||
key={article.uuid}
|
||||
className="bg-white rounded-2xl shadow-soft hover:shadow-card transition-all duration-300 hover:-translate-y-2 overflow-hidden group"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
>
|
||||
{/* Article Image */}
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
{article.image ? (
|
||||
<img
|
||||
src={article.image}
|
||||
alt={article.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-primary/10 to-accent/10 flex items-center justify-center">
|
||||
<svg className="w-16 h-16 text-primary/30" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</div>
|
||||
|
||||
{/* Article Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Date */}
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/>
|
||||
</svg>
|
||||
{formatDate(article.published_at)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-xl font-bold text-gray-900 line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
{/* Excerpt */}
|
||||
<p className="text-gray-600 text-sm leading-relaxed line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
{/* Read More Link */}
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
to={`/artikel/${article.slug}`}
|
||||
className="inline-flex items-center text-primary font-semibold text-sm hover:text-primary-dark transition-colors group"
|
||||
>
|
||||
Baca Selengkapnya
|
||||
<svg className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* View All Button */}
|
||||
{!loading && !error && articles.length > 0 && (
|
||||
<motion.div
|
||||
className="text-center mt-12"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Link
|
||||
to="/artikel"
|
||||
className="inline-flex items-center px-8 py-4 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 hover:shadow-lg hover:-translate-y-1 font-semibold shadow-soft"
|
||||
>
|
||||
Lihat Semua Artikel
|
||||
<svg className="w-5 h-5 ml-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,34 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MapPin, Phone, Mail, Instagram, MessageCircle, Globe, Clock, ShieldAlert } from 'lucide-react';
|
||||
import { contactService } from '../services/contactService';
|
||||
import { fadeIn, slideUp, staggerChildren } from '../utils/motionVariants';
|
||||
import { SpotlightCard } from './ui/spotlight-card';
|
||||
import { FlowButton } from './ui/flow-button';
|
||||
|
||||
const Contact = () => {
|
||||
const [contactInfo, setContactInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Temporary disable API calls to prevent 404 errors
|
||||
const mockContactInfo = {
|
||||
address: 'Jl. Mastrip PO Box 164, Jember 68121, Jawa Timur, Indonesia',
|
||||
phone: '+62 331-123456',
|
||||
email: 'satgasppkpt@polije.ac.id',
|
||||
instagram: '@satgasppkpt_polije',
|
||||
whatsapp: '+6281234567890',
|
||||
facebook: 'SatgasPPKPTPolije',
|
||||
twitter: '@SatgasPPKPTPolije'
|
||||
};
|
||||
|
||||
setContactInfo(mockContactInfo);
|
||||
setLoading(false);
|
||||
fetchContactInfo();
|
||||
}, []);
|
||||
|
||||
const fetchContactInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await contactService.get();
|
||||
if (response.success) {
|
||||
setContactInfo(response.data);
|
||||
} else {
|
||||
setError('Failed to load contact information');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load contact information');
|
||||
console.error('Error fetching contact info:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultContact = {
|
||||
address: 'Jl. Mastrip PO Box 164, Jember 68121, Jawa Timur, Indonesia',
|
||||
phone: '+62 331-123456',
|
||||
|
|
@ -37,181 +41,250 @@ const Contact = () => {
|
|||
|
||||
const contactMethods = [
|
||||
{
|
||||
icon: <Phone className="w-6 h-6" />,
|
||||
label: 'Telepon Kantor',
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/>
|
||||
</svg>
|
||||
),
|
||||
label: 'Telepon',
|
||||
value: contact.phone,
|
||||
href: `tel:${contact.phone}`,
|
||||
color: 'text-blue-500',
|
||||
spotlight: 'rgba(59, 130, 246, 0.2)'
|
||||
color: 'text-primary'
|
||||
},
|
||||
{
|
||||
icon: <Mail className="w-6 h-6" />,
|
||||
label: 'Email Resmi',
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||
</svg>
|
||||
),
|
||||
label: 'Email',
|
||||
value: contact.email,
|
||||
href: `mailto:${contact.email}`,
|
||||
color: 'text-purple-500',
|
||||
spotlight: 'rgba(168, 85, 247, 0.2)'
|
||||
color: 'text-primary'
|
||||
},
|
||||
{
|
||||
icon: <Instagram className="w-6 h-6" />,
|
||||
label: 'Instagram',
|
||||
value: contact.instagram,
|
||||
href: `https://instagram.com/${contact.instagram?.replace('@', '')}`,
|
||||
color: 'text-pink-500',
|
||||
spotlight: 'rgba(236, 72, 153, 0.2)'
|
||||
},
|
||||
{
|
||||
icon: <MessageCircle className="w-6 h-6" />,
|
||||
label: 'WhatsApp Admin',
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.149-.67.149-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414-.074-.123-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413"/>
|
||||
</svg>
|
||||
),
|
||||
label: 'WhatsApp',
|
||||
value: contact.whatsapp,
|
||||
href: `https://wa.me/${contact.whatsapp?.replace(/[^0-9]/g, '')}`,
|
||||
color: 'text-green-500',
|
||||
spotlight: 'rgba(34, 197, 94, 0.2)'
|
||||
color: 'text-accent'
|
||||
}
|
||||
];
|
||||
|
||||
const socialMedia = [
|
||||
{
|
||||
name: 'Instagram',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zM5.838 12a6.162 6.162 0 1112.324 0 6.162 6.162 0 01-12.324 0zM12 16a4 4 0 110-8 4 4 0 010 8zm4.965-10.405a1.44 1.44 0 112.881.001 1.44 1.44 0 01-2.881-.001z"/>
|
||||
</svg>
|
||||
),
|
||||
href: `https://instagram.com/${contact.instagram?.replace('@', '')}`,
|
||||
color: 'hover:text-pink-600'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="contact" className="py-24 bg-gray-50 dark:bg-gray-900 relative overflow-hidden">
|
||||
{/* Background Decorations */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-[10%] left-[5%] w-[500px] h-[500px] bg-primary/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-[10%] right-[5%] w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-3xl"></div>
|
||||
<section id="contact" className="py-20 bg-gray-50 relative overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute top-20 right-20 w-72 h-72 bg-primary rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-20 left-20 w-96 h-96 bg-accent rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
className="text-center mb-16"
|
||||
<motion.div
|
||||
className="text-center mb-20"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<motion.h2
|
||||
className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-6"
|
||||
<motion.h2
|
||||
className="text-4xl md:text-5xl font-bold text-gray-900 mb-6"
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
Hubungi <span className="text-primary">Kami</span>
|
||||
</motion.h2>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto leading-relaxed">
|
||||
Kami siap membantu Anda. Jangan ragu untuk menghubungi kami melalui berbagai saluran komunikasi yang tersedia. Privasi Anda adalah prioritas kami.
|
||||
</p>
|
||||
<motion.p
|
||||
className="text-xl text-gray-600 max-w-3xl mx-auto leading-relaxed"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
Kami siap membantu Anda. Jangan ragu untuk menghubungi kami melalui berbagai metode komunikasi yang tersedia.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Left Column: Contact Info & Emergency */}
|
||||
<div className="lg:col-span-5 space-y-8">
|
||||
|
||||
{/* Emergency Card */}
|
||||
<SpotlightCard
|
||||
className="rounded-3xl p-8 border border-red-200/50 bg-red-50/50 dark:bg-red-900/20 dark:border-red-500/30 backdrop-blur-sm"
|
||||
spotlightColor="rgba(239, 68, 68, 0.15)"
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/50 rounded-xl flex items-center justify-center flex-shrink-0 text-red-600 dark:text-red-400">
|
||||
<ShieldAlert className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Bantuan Darurat 24/7</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6 text-sm">
|
||||
Jika Anda dalam situasi darurat atau bahaya, segera hubungi tim Satgas PPKS melalui saluran khusus ini.
|
||||
</p>
|
||||
<FlowButton
|
||||
text="Hubungi Darurat Sekarang"
|
||||
href={`https://wa.me/${contact.whatsapp?.replace(/[^0-9]/g, '')}`}
|
||||
target="_blank"
|
||||
colorStr="#ef4444"
|
||||
hoverColorStr="#dc2626"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
|
||||
{/* General Contact Info */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{contactMethods.map((method, index) => (
|
||||
<SpotlightCard
|
||||
key={index}
|
||||
className="rounded-2xl p-6 border border-white/20 bg-white/60 dark:bg-black/20 dark:border-white/10 backdrop-blur-md shadow-sm hover:shadow-md transition-all group"
|
||||
spotlightColor={method.spotlight}
|
||||
>
|
||||
<a
|
||||
href={method.href}
|
||||
target={method.href.startsWith('http') ? '_blank' : '_self'}
|
||||
rel={method.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className="flex flex-col items-center text-center h-full"
|
||||
>
|
||||
<div className={`w-12 h-12 rounded-full bg-gray-50 dark:bg-white/5 flex items-center justify-center mb-4 ${method.color} group-hover:scale-110 transition-transform duration-300`}>
|
||||
{method.icon}
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">{method.label}</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 break-all">{method.value}</p>
|
||||
</a>
|
||||
</SpotlightCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Operating Hours */}
|
||||
<div className="bg-white/60 dark:bg-black/20 border border-white/20 dark:border-white/10 rounded-2xl p-6 backdrop-blur-sm">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">Jam Operasional Kantor</h4>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li className="flex justify-between">
|
||||
<span>Senin - Jumat</span>
|
||||
<span className="font-medium">08.00 - 16.00 WIB</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>Sabtu - Minggu</span>
|
||||
<span className="font-medium text-red-500">Tutup (Kecuali Darurat)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<motion.div
|
||||
className="flex justify-center items-center py-20"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin"></div>
|
||||
<p className="text-gray-600 font-medium">Memuat informasi kontak...</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Right Column: Map */}
|
||||
<div className="lg:col-span-7 h-full min-h-[500px]">
|
||||
<SpotlightCard
|
||||
className="h-full rounded-3xl p-2 border border-white/20 bg-white/60 dark:bg-black/20 dark:border-white/10 backdrop-blur-md shadow-xl overflow-hidden"
|
||||
spotlightColor="rgba(255, 255, 255, 0.1)"
|
||||
>
|
||||
<div className="relative w-full h-full rounded-2xl overflow-hidden group">
|
||||
<iframe
|
||||
title="Lokasi Politeknik Negeri Jember"
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3949.424564483758!2d113.72093787447761!3d-8.16021369187063!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x2dd695b617d8f623%3A0x280e466373737b60!2sPoliteknik%20Negeri%20Jember!5e0!3m2!1sid!2sid!4v1708150000000!5m2!1sid!2sid"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0, minHeight: '500px' }}
|
||||
allowFullScreen=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="grayscale hover:grayscale-0 transition-all duration-700 ease-in-out"
|
||||
></iframe>
|
||||
|
||||
{/* Map Overlay Info */}
|
||||
<div className="absolute bottom-6 left-6 right-6 bg-white/90 dark:bg-black/80 backdrop-blur-md p-4 rounded-xl border border-white/20 shadow-lg translate-y-full opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-500">
|
||||
<div className="flex items-start space-x-3">
|
||||
<MapPin className="w-5 h-5 text-red-500 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h5 className="font-bold text-gray-900 dark:text-white">Kampus Politeknik Negeri Jember</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">{contact.address}</p>
|
||||
<a
|
||||
href="https://maps.app.goo.gl/politekniknegerijember"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-2 text-xs font-semibold text-primary hover:underline"
|
||||
{/* Contact Content */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Contact Information */}
|
||||
<motion.div
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<div className="bg-white rounded-3xl p-8 shadow-soft">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-8">Informasi Kontak</h3>
|
||||
|
||||
{/* Contact Methods */}
|
||||
<div className="space-y-6 mb-8">
|
||||
{contactMethods.map((method, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="flex items-start space-x-4 p-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
>
|
||||
<div className={`w-12 h-12 bg-gradient-to-br from-primary/10 to-accent/10 dark:from-primary/20 dark:to-accent/20 rounded-xl flex items-center justify-center ${method.color} flex-shrink-0`}>
|
||||
<div className={method.color === 'text-primary' ? 'text-primary dark:text-primary-light' : method.color === 'text-accent' ? 'text-accent dark:text-accent-light' : 'text-gray-700 dark:text-gray-300'}>
|
||||
{method.icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">{method.label}</h4>
|
||||
<a
|
||||
href={method.href}
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light transition-colors"
|
||||
target={method.href.startsWith('http') ? '_blank' : '_self'}
|
||||
rel={method.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
Buka di Google Maps →
|
||||
{method.value}
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="p-4 bg-gray-50 rounded-xl">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-primary/10 to-accent/10 rounded-xl flex items-center justify-center text-primary flex-shrink-0">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-1">Alamat</h4>
|
||||
<p className="text-gray-600 leading-relaxed">{contact.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
</div>
|
||||
|
||||
{/* Social Media */}
|
||||
<div className="mt-8 pt-8 border-t border-gray-200">
|
||||
<h4 className="font-semibold text-gray-900 mb-4">Ikuti Kami</h4>
|
||||
<div className="flex space-x-4">
|
||||
{socialMedia.map((social, index) => (
|
||||
<motion.a
|
||||
key={index}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`w-12 h-12 bg-gray-100 rounded-xl flex items-center justify-center text-gray-600 ${social.color} transition-all duration-300`}
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 * index }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
>
|
||||
{social.icon}
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Emergency Contact */}
|
||||
<motion.div
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="bg-gradient-to-br from-danger/10 to-danger/5 border border-danger/20 rounded-3xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-danger/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-danger" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Butuh Bantuan Darurat?</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Jika Anda atau orang lain berada dalam situasi darurat, segera hubungi kami untuk respons cepat 24/7.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<motion.a
|
||||
href={`https://wa.me/${contact.whatsapp?.replace(/[^0-9]/g, '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-full px-6 py-4 bg-danger text-white rounded-xl hover:bg-danger-dark transition-all duration-300 font-semibold shadow-soft hover:shadow-lg"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.3 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.149-.67.149-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414-.074-.123-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413"/>
|
||||
</svg>
|
||||
Hubungi WhatsApp Darurat
|
||||
</motion.a>
|
||||
|
||||
<motion.div
|
||||
className="text-center p-4 bg-white/80 rounded-xl"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2 text-gray-600">
|
||||
<svg className="w-5 h-5 text-accent" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
<span className="font-medium">Respons 24/7 - Privasi Terjamin</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,135 +1,237 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Facebook, Instagram, Twitter, Linkedin, Heart, Shield, Mail, Phone, MapPin } from 'lucide-react';
|
||||
import { fadeIn } from '../utils/motionVariants';
|
||||
|
||||
const Footer = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const footerLinks = {
|
||||
'Menu Utama': [
|
||||
{ name: 'Beranda', href: '#hero' },
|
||||
{ name: 'Tentang Kami', href: '#about' },
|
||||
{ name: 'Layanan', href: '#services' },
|
||||
{ name: 'Artikel', href: '/artikel' },
|
||||
],
|
||||
'Layanan': [
|
||||
{ name: 'Pelaporan Online', href: '#services' },
|
||||
{ name: 'Konsultasi Psikologi', href: '#contact' },
|
||||
{ name: 'Pendampingan Hukum', href: '#contact' },
|
||||
{ name: 'Edukasi & Sosialisasi', href: '/artikel' },
|
||||
],
|
||||
'Bantuan': [
|
||||
{ name: 'Kontak Darurat', href: '#contact' },
|
||||
{ name: 'Prosedur Lapor', href: '#services' },
|
||||
{ name: 'FAQ', href: '#faq' },
|
||||
{ name: 'Kebijakan Privasi', href: '#' },
|
||||
]
|
||||
};
|
||||
const quickLinks = [
|
||||
{ name: 'Beranda', href: '#hero' },
|
||||
{ name: 'Tentang Kami', href: '#about' },
|
||||
{ name: 'Layanan', href: '#services' },
|
||||
{ name: 'Artikel', href: '/artikel' },
|
||||
{ name: 'Kontak', href: '#contact' }
|
||||
];
|
||||
|
||||
const services = [
|
||||
{ name: 'WhatsApp Reporting', href: 'https://wa.me/6281234567890' },
|
||||
{ name: 'Form Reporting', href: '/artikel' },
|
||||
{ name: 'Konsultasi', href: '#contact' },
|
||||
{ name: 'Pendampingan', href: '#contact' }
|
||||
];
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: <Instagram className="w-5 h-5" />, href: 'https://instagram.com/satgasppkpt_polije', label: 'Instagram' },
|
||||
{ icon: <Twitter className="w-5 h-5" />, href: '#', label: 'Twitter' },
|
||||
{ icon: <Facebook className="w-5 h-5" />, href: '#', label: 'Facebook' },
|
||||
{ icon: <Linkedin className="w-5 h-5" />, href: '#', label: 'LinkedIn' },
|
||||
{
|
||||
name: 'Instagram',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zM5.838 12a6.162 6.162 0 1112.324 0 6.162 6.162 0 01-12.324 0zM12 16a4 4 0 110-8 4 4 0 010 8zm4.965-10.405a1.44 1.44 0 112.881.001 1.44 1.44 0 01-2.881-.001z"/>
|
||||
</svg>
|
||||
),
|
||||
href: 'https://instagram.com/satgasppkpt_polije'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<footer className="bg-white/80 dark:bg-black/80 backdrop-blur-md border-t border-gray-200/50 dark:border-white/5 pt-20 pb-10 relative overflow-hidden">
|
||||
{/* Background Gradients */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_var(--tw-gradient-stops))] from-primary/5 via-transparent to-transparent opacity-70"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_left,_var(--tw-gradient-stops))] from-blue-500/5 via-transparent to-transparent opacity-70"></div>
|
||||
<footer className="bg-gray-900 text-white relative overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute top-10 right-10 w-64 h-64 bg-primary rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-10 left-10 w-96 h-96 bg-accent rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-8 mb-16">
|
||||
{/* Brand Column */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
{/* Main Footer Content */}
|
||||
<div className="py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12">
|
||||
{/* Logo and Description */}
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<img
|
||||
src="/logo_polijecare.png"
|
||||
alt="PolijeCare Logo"
|
||||
className="w-14 h-14 object-contain filter drop-shadow-sm"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
PolijeCare
|
||||
</h3>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] font-semibold text-primary">
|
||||
Satgas PPKS Polije
|
||||
</p>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-primary to-accent rounded-full flex items-center justify-center shadow-lg">
|
||||
<div className="w-7 h-7 bg-white rounded-full flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-gradient-to-br from-primary to-accent rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-accent rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">PolijeCare</span>
|
||||
</div>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
Satgas PPKPT Politeknik Negeri Jember - Layanan pengaduan dan pendampingan terpercaya bagi sivitas akademika.
|
||||
</p>
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="flex space-x-4">
|
||||
{socialLinks.map((social, index) => (
|
||||
<motion.a
|
||||
key={index}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-10 h-10 bg-gray-800 rounded-xl flex items-center justify-center text-gray-400 hover:text-accent transition-all duration-300"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
whileHover={{ scale: 1.1, y: -2 }}
|
||||
>
|
||||
{social.icon}
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<p className="text-gray-500 dark:text-gray-400 leading-relaxed max-w-sm text-sm">
|
||||
Mewujudkan lingkungan kampus yang aman, inklusif, dan bebas kekerasan seksual. Kami hadir untuk melayani, melindungi, dan mendampingi sivitas akademika Polije.
|
||||
</p>
|
||||
{/* Quick Links */}
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h3 className="text-lg font-bold text-white">Quick Links</h3>
|
||||
<ul className="space-y-3">
|
||||
{quickLinks.map((link, index) => (
|
||||
<motion.li
|
||||
key={index}
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
>
|
||||
<a
|
||||
href={link.href}
|
||||
className="text-gray-300 hover:text-accent transition-colors duration-300 flex items-center group"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 transform group-hover:translate-x-1 transition-transform" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
{link.name}
|
||||
</a>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{socialLinks.map((social, index) => (
|
||||
{/* Services */}
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<h3 className="text-lg font-bold text-white">Layanan</h3>
|
||||
<ul className="space-y-3">
|
||||
{services.map((service, index) => (
|
||||
<motion.li
|
||||
key={index}
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
>
|
||||
<a
|
||||
href={service.href}
|
||||
className="text-gray-300 hover:text-accent transition-colors duration-300 flex items-center group"
|
||||
target={service.href.startsWith('http') ? '_blank' : '_self'}
|
||||
rel={service.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 transform group-hover:translate-x-1 transition-transform" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
{service.name}
|
||||
</a>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<h3 className="text-lg font-bold text-white">Kontak Darurat</h3>
|
||||
<div className="space-y-4">
|
||||
<motion.a
|
||||
key={index}
|
||||
href={social.href}
|
||||
href="https://wa.me/6281234567890"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={social.label}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full bg-gray-100 dark:bg-white/5 text-gray-500 dark:text-gray-400 hover:bg-primary hover:text-white dark:hover:bg-primary dark:hover:text-white transition-all duration-300"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="flex items-center space-x-3 text-gray-300 hover:text-accent transition-colors"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ x: 4 }}
|
||||
>
|
||||
{React.cloneElement(social.icon, { className: "w-4 h-4" })}
|
||||
<div className="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.149-.67.149-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414-.074-.123-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">WhatsApp Darurat</div>
|
||||
<div className="text-sm text-gray-400">24/7 Available</div>
|
||||
</div>
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links Columns */}
|
||||
<div className="lg:col-span-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12">
|
||||
{Object.entries(footerLinks).map(([title, links], columnIndex) => (
|
||||
<div key={title} className="space-y-6">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white text-sm uppercase tracking-wider">{title}</h4>
|
||||
<ul className="space-y-3">
|
||||
{links.map((link, index) => (
|
||||
<motion.li
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 + (index * 0.05) }}
|
||||
>
|
||||
<a
|
||||
href={link.href}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-primary dark:hover:text-primary transition-colors inline-flex items-center group"
|
||||
>
|
||||
<span className="relative overflow-hidden">
|
||||
{link.name}
|
||||
<span className="absolute bottom-0 left-0 w-full h-[1px] bg-primary transform -translate-x-full group-hover:translate-x-0 transition-transform duration-300"></span>
|
||||
</span>
|
||||
</a>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
<motion.div
|
||||
className="flex items-center space-x-3 text-gray-300"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<div className="w-10 h-10 bg-primary/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Alamat</div>
|
||||
<div className="text-sm text-gray-400">Jl. Mastrip PO Box 164, Jember</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="pt-8 border-t border-gray-200/50 dark:border-white/5 flex flex-col md:flex-row justify-between items-center gap-6 text-xs text-gray-500 dark:text-gray-500">
|
||||
<p>© {currentYear} PolijeCare. All rights reserved.</p>
|
||||
<div className="flex gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span>Jember, East Java</span>
|
||||
<div className="py-6 border-t border-gray-800">
|
||||
<motion.div
|
||||
className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<div className="text-gray-400 text-sm">
|
||||
© {currentYear} PolijeCare. All rights reserved.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-3 h-3" />
|
||||
<span>satgasppkpt@polije.ac.id</span>
|
||||
<div className="flex items-center space-x-6 text-gray-400 text-sm">
|
||||
<a href="#" className="hover:text-accent transition-colors">Privacy Policy</a>
|
||||
<a href="#" className="hover:text-accent transition-colors">Terms of Service</a>
|
||||
<a href="#" className="hover:text-accent transition-colors">Cookie Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Component as ReportButton } from './button';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { fadeIn, slideUp, slideLeft, slideRight } from '../utils/motionVariants';
|
||||
|
||||
const Hero = ({ heroData }) => {
|
||||
const navigate = useNavigate();
|
||||
const defaultHero = {
|
||||
title: 'Aman Bicara, Aman Melapor',
|
||||
subtitle: 'Satgas PPKPT Politeknik Negeri Jember',
|
||||
|
|
@ -16,32 +13,32 @@ const Hero = ({ heroData }) => {
|
|||
const hero = heroData || defaultHero;
|
||||
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
className="min-h-screen flex items-center bg-soft-white relative overflow-hidden pt-16 transition-colors duration-300"
|
||||
<section
|
||||
id="hero"
|
||||
className="min-h-screen flex items-center bg-gradient-to-br from-gray-50 to-purple-50 dark:from-gray-900 dark:to-purple-900 relative overflow-hidden pt-16 transition-colors duration-300"
|
||||
>
|
||||
{/* Background Decorations */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<motion.div
|
||||
className="absolute top-[-10%] left-[-5%] w-[500px] h-[500px] bg-primary-light/30 rounded-full blur-[100px]"
|
||||
animate={{
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute top-20 left-10 w-72 h-72 bg-accent/10 rounded-full blur-3xl"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.3, 0.5, 0.3]
|
||||
opacity: [0.3, 0.5, 0.3]
|
||||
}}
|
||||
transition={{
|
||||
duration: 8,
|
||||
transition={{
|
||||
duration: 8,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute bottom-[-10%] right-[-5%] w-[600px] h-[600px] bg-primary/10 rounded-full blur-[120px]"
|
||||
animate={{
|
||||
<motion.div
|
||||
className="absolute bottom-20 right-10 w-96 h-96 bg-primary/10 rounded-full blur-3xl"
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
opacity: [0.3, 0.4, 0.3]
|
||||
opacity: [0.3, 0.4, 0.3]
|
||||
}}
|
||||
transition={{
|
||||
duration: 10,
|
||||
transition={{
|
||||
duration: 10,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 2
|
||||
|
|
@ -49,25 +46,25 @@ const Hero = ({ heroData }) => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[1440px] mx-auto px-8 sm:px-12 lg:px-16 relative z-10">
|
||||
<div className="grid lg:grid-cols-2 gap-8 lg:gap-12 items-center">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
{/* Left Content */}
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
<motion.div
|
||||
className="space-y-8"
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<motion.h1
|
||||
className="text-5xl md:text-6xl lg:text-7xl font-bold text-gray-900 dark:text-white leading-tight"
|
||||
<motion.h1
|
||||
className="text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-tight"
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
|
|
@ -75,9 +72,9 @@ const Hero = ({ heroData }) => {
|
|||
>
|
||||
{hero.title}
|
||||
</motion.h1>
|
||||
|
||||
<motion.h2
|
||||
className="text-2xl md:text-3xl lg:text-4xl font-semibold text-[#191970] dark:text-blue-300"
|
||||
|
||||
<motion.h2
|
||||
className="text-xl md:text-2xl lg:text-3xl font-semibold text-primary dark:text-primary-light"
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
|
|
@ -87,35 +84,28 @@ const Hero = ({ heroData }) => {
|
|||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
className="text-xl text-gray-600 dark:text-gray-300 leading-relaxed max-w-2xl"
|
||||
<motion.p
|
||||
className="text-lg text-gray-600 dark:text-gray-300 leading-relaxed max-w-xl"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
{hero.description ? (
|
||||
hero.description
|
||||
) : (
|
||||
<>
|
||||
Kami siap mendengar dan membantu Anda dengan{' '}
|
||||
<span className="highlight-marker" style={{ '--delay': '1' }}>profesionalisme</span> dan{' '}
|
||||
<span className="highlight-marker" style={{ '--delay': '1.6' }}>kerahasiaan terjamin</span>.{' '}
|
||||
Setiap laporan akan ditangani dengan{' '}
|
||||
<span className="highlight-marker" style={{ '--delay': '2.2' }}>empati</span> dan{' '}
|
||||
<span className="highlight-marker" style={{ '--delay': '2.6' }}>seksama</span>.
|
||||
</>
|
||||
)}
|
||||
{hero.description}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="flex flex-row gap-4 pt-4 items-start"
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row gap-4 pt-4"
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<motion.div
|
||||
<motion.a
|
||||
href="https://wa.me/6281234567890"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-8 py-4 bg-danger text-white rounded-xl hover:bg-danger-dark transition-all duration-300 hover:shadow-lg hover:-translate-y-1 font-semibold text-center shadow-soft"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
|
|
@ -123,17 +113,9 @@ const Hero = ({ heroData }) => {
|
|||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<ReportButton
|
||||
icon={<Icon icon="solar:phone-calling-bold-duotone" />}
|
||||
title="Butuh Bantuan Darurat"
|
||||
size="sm"
|
||||
className="rounded-full bg-red-600 hover:bg-red-700 border-0"
|
||||
gradientLight={{ from: "from-red-600", via: "via-red-600", to: "to-red-600" }}
|
||||
gradientDark={{ from: "from-red-600", via: "via-red-600", to: "to-red-600" }}
|
||||
onClick={() => window.open('https://wa.me/6281234567890', '_blank')}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
Butuh Bantuan Darurat
|
||||
</motion.a>
|
||||
|
||||
<motion.div
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
|
|
@ -142,24 +124,42 @@ const Hero = ({ heroData }) => {
|
|||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<ReportButton
|
||||
icon={<Icon icon="solar:document-add-bold-duotone" />}
|
||||
title="Buat Laporan"
|
||||
size="sm"
|
||||
className="rounded-full bg-[#191970] hover:bg-blue-900 border-0"
|
||||
gradientLight={{ from: "from-[#191970]", via: "via-[#191970]", to: "to-[#191970]" }}
|
||||
gradientDark={{ from: "from-[#191970]", via: "via-[#191970]", to: "to-[#191970]" }}
|
||||
onClick={() => navigate('/artikel')}
|
||||
/>
|
||||
<Link
|
||||
to="/artikel"
|
||||
className="px-8 py-4 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 hover:shadow-lg hover:-translate-y-1 font-semibold text-center shadow-soft inline-block w-full sm:w-auto"
|
||||
>
|
||||
Buat Laporan
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
|
||||
<motion.div
|
||||
className="flex flex-wrap gap-6 pt-8"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ delay: 0.9 }}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-accent rounded-full"></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300 font-medium">100% Rahasia</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-accent rounded-full"></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300 font-medium">Profesional</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-accent rounded-full"></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300 font-medium">24/7 Support</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Right Content - Logo & Branding */}
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="relative lg:pl-12"
|
||||
variants={slideRight}
|
||||
initial="hidden"
|
||||
|
|
@ -167,26 +167,82 @@ const Hero = ({ heroData }) => {
|
|||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
>
|
||||
{/* Main Logo Container */}
|
||||
<motion.div
|
||||
className="relative z-10 flex justify-end lg:pr-4"
|
||||
<motion.div
|
||||
className="relative z-10"
|
||||
animate={{
|
||||
y: [0, -20, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 6,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/gambar_header.png"
|
||||
alt="header gambar"
|
||||
className="w-full max-w-[550px] h-auto object-cover"
|
||||
/>
|
||||
<div className="bg-gradient-to-br from-primary/10 to-accent/10 dark:from-primary/20 dark:to-accent/20 rounded-3xl p-12 backdrop-blur-sm border border-white/50 dark:border-gray-700/50 shadow-soft">
|
||||
<div className="aspect-square max-w-md mx-auto flex flex-col items-center justify-center space-y-6">
|
||||
{/* Logo Image */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.05, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
<div className="w-48 h-48 bg-white dark:bg-gray-800 rounded-2xl shadow-lg flex items-center justify-center p-6">
|
||||
<img
|
||||
src="/logo_polijecare.png"
|
||||
alt="Polijecare Logo"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
{/* Glow Effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 rounded-2xl blur-xl -z-10"></div>
|
||||
</motion.div>
|
||||
|
||||
{/* Brand Text */}
|
||||
<motion.div
|
||||
className="text-center space-y-2"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">Polijecare</h3>
|
||||
<p className="text-primary dark:text-primary-light font-medium">Satgas PPKPT Polije</p>
|
||||
<div className="w-16 h-1 bg-gradient-to-r from-primary to-accent rounded-full mx-auto"></div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Background Shape */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-100/50 dark:from-purple-900/20 to-accent/10 dark:to-accent/20 rounded-3xl blur-2xl -z-10"></div>
|
||||
{/* Background Shape */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-100/50 dark:from-purple-900/20 to-accent/10 dark:to-accent/20 rounded-3xl blur-2xl -z-10"></div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Brand Stats Banner - Glassmorphic Light Design */}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<motion.div
|
||||
className="absolute bottom-8 left-1/2 transform -translate-x-1/2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1.5, duration: 0.8 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, 10, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="flex flex-col items-center space-y-2"
|
||||
>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500 font-medium">Scroll ke bawah</span>
|
||||
<div className="w-6 h-10 border-2 border-gray-400 dark:border-gray-500 rounded-full flex justify-center">
|
||||
<div className="w-1 h-3 bg-gray-600 dark:bg-gray-400 rounded-full mt-2"></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,373 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FiMail, FiLock, FiAlertCircle, FiEye, FiEyeOff, FiX } from 'react-icons/fi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
// Motion variants for animations
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { duration: 0.3 } }
|
||||
};
|
||||
|
||||
const slideUp = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.3, delay: 0.1 } }
|
||||
};
|
||||
|
||||
const modalVariants = {
|
||||
hidden: { opacity: 0, scale: 0.95 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.2 }
|
||||
}
|
||||
};
|
||||
|
||||
const backdropVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { duration: 0.2 } },
|
||||
exit: { opacity: 0, transition: { duration: 0.2 } }
|
||||
};
|
||||
|
||||
const LoginModal = ({ isOpen, onClose }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loginError, setLoginError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
const validateEmail = (email) => {
|
||||
const emailRegex = /^[^\s@]+@polije\.ac\.id$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'Email wajib diisi';
|
||||
} else if (!validateEmail(formData.email)) {
|
||||
newErrors.email = 'Email harus menggunakan domain @polije.ac.id';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Password wajib diisi';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'Password minimal 6 karakter';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear login error when user starts typing
|
||||
if (loginError) {
|
||||
setLoginError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setLoginError('');
|
||||
|
||||
try {
|
||||
const result = await login({
|
||||
email: formData.email,
|
||||
password: formData.password
|
||||
});
|
||||
|
||||
console.log('🔍 Login result:', result);
|
||||
|
||||
if (result && result.success) {
|
||||
console.log('✅ Login successful');
|
||||
onClose(); // Close modal
|
||||
navigate('/redirect');
|
||||
} else {
|
||||
console.error('❌ Login failed:', result);
|
||||
const errorMessage = result?.message || result?.error || 'Login gagal. Silakan coba lagi.';
|
||||
setLoginError(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Login error:', error);
|
||||
console.error('Error details:', error.response?.data || error.message);
|
||||
const errorMessage = error.response?.data?.message || error.message || 'Terjadi kesalahan. Silakan coba lagi.';
|
||||
setLoginError(errorMessage);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isLoading) {
|
||||
onClose();
|
||||
// Reset form
|
||||
setFormData({ email: '', password: '' });
|
||||
setErrors({});
|
||||
setLoginError('');
|
||||
setShowPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
variants={backdropVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
exit="exit"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
className="relative w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden"
|
||||
variants={modalVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
exit="exit"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
className="absolute top-4 right-4 z-10 p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FiX className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 sm:p-8">
|
||||
{/* Header with Protection Icon */}
|
||||
<motion.div
|
||||
className="text-center mb-6"
|
||||
variants={slideUp}
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-primary to-accent rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<img
|
||||
src="/logo_polijecare.png"
|
||||
alt="Polijecare Logo"
|
||||
className="w-10 h-10 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
PolijeCare
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Portal Layanan Perlindungan & Kesejahteraan
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Login Form */}
|
||||
<motion.form
|
||||
onSubmit={handleSubmit}
|
||||
variants={slideUp}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 text-center leading-relaxed">
|
||||
Masuk menggunakan akun resmi Polije<br />
|
||||
untuk menjaga keamanan dan kerahasiaan data Anda.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Error Alert */}
|
||||
{loginError && (
|
||||
<motion.div
|
||||
className="mb-6 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-3 text-red-600 dark:text-red-400 text-sm"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<FiAlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{loginError}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email Polije
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FiMail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full pl-10 pr-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors dark:bg-gray-700 dark:border-gray-600 dark:text-white ${
|
||||
errors.email ? 'border-red-300 dark:border-red-600' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="nama@polije.ac.id"
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<motion.p
|
||||
className="mt-1 text-sm text-red-600 dark:text-red-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
{errors.email}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="mb-6">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FiLock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full pl-10 pr-12 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors dark:bg-gray-700 dark:border-gray-600 dark:text-white ${
|
||||
errors.password ? 'border-red-300 dark:border-red-600' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Masukkan password"
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors disabled:opacity-50"
|
||||
onClick={togglePasswordVisibility}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? <FiEyeOff className="w-5 h-5" /> : <FiEye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<motion.p
|
||||
className="mt-1 text-sm text-red-600 dark:text-red-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
{errors.password}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<motion.button
|
||||
type="submit"
|
||||
className="w-full py-3 px-4 bg-primary text-white font-medium rounded-lg shadow-lg hover:bg-primary-dark hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
|
||||
disabled={isLoading}
|
||||
whileHover={{ scale: isLoading ? 1 : 1.02 }}
|
||||
whileTap={{ scale: isLoading ? 1 : 0.98 }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
<span>Masuk...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Masuk'
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
|
||||
{/* Footer */}
|
||||
<motion.div
|
||||
className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"
|
||||
variants={slideUp}
|
||||
>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center leading-relaxed">
|
||||
Dilindungi dengan enkripsi end-to-end<br />
|
||||
© 2026 PolijeCare - Politeknik Negeri Jember
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Background Decoration */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -top-20 -right-20 w-40 h-40 bg-gradient-to-br from-primary/10 to-accent/5 rounded-full blur-2xl"></div>
|
||||
<div className="absolute -bottom-16 -left-16 w-32 h-32 bg-gradient-to-br from-accent/10 to-primary/5 rounded-full blur-xl"></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
||||
|
|
@ -1,19 +1,13 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Home, Info, FileText, BookOpen, Phone } from 'lucide-react';
|
||||
import { ExpandableTabs } from "@/components/ui/expandable-tabs";
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { fadeIn, slideDown } from '../utils/motionVariants';
|
||||
import Switch from './sky-toggle';
|
||||
import LoginModal from './LoginModal';
|
||||
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
|
||||
const Navbar = () => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [activeLink, setActiveLink] = useState('#hero');
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const { isAuthenticated, user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
|
@ -26,62 +20,14 @@ const Navbar = () => {
|
|||
{ name: 'Kontak', href: '#contact' }
|
||||
];
|
||||
|
||||
const tabs = [
|
||||
{ title: "Beranda", icon: Home },
|
||||
{ title: "Tentang Kami", icon: Info },
|
||||
{ title: "Cara Melapor", icon: FileText },
|
||||
{ title: "Artikel", icon: BookOpen },
|
||||
{ title: "Kontak", icon: Phone },
|
||||
];
|
||||
|
||||
const handleNavClick = (href) => {
|
||||
if (href.startsWith('#')) {
|
||||
const element = document.querySelector(href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
const handleScroll = () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
// Logic for switching navbar type (Standard vs Expandable)
|
||||
const aboutSection = document.getElementById('about');
|
||||
const threshold = aboutSection ? aboutSection.offsetTop - 400 : window.innerHeight - 200;
|
||||
setIsScrolled(window.scrollY > threshold);
|
||||
|
||||
// ScrollSpy Logic
|
||||
const sections = navLinks.map(link => link.href.substring(1));
|
||||
let currentSection = "";
|
||||
|
||||
for (const section of sections) {
|
||||
const element = document.getElementById(section);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.top <= 150 && rect.bottom >= 150) {
|
||||
currentSection = "#" + section;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSection && currentSection !== activeLink) {
|
||||
setActiveLink(currentSection);
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [navLinks, activeLink]);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
|
|
@ -90,7 +36,7 @@ const Navbar = () => {
|
|||
|
||||
const handleDashboardRedirect = () => {
|
||||
if (!user) return;
|
||||
|
||||
|
||||
switch (user.role) {
|
||||
case 'user':
|
||||
navigate('/user/dashboard');
|
||||
|
|
@ -107,249 +53,226 @@ const Navbar = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleNavClick = (href) => {
|
||||
if (href.startsWith('#')) {
|
||||
const element = document.querySelector(href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
{isScrolled ? (
|
||||
<motion.div
|
||||
key="expandable-tabs"
|
||||
initial={{ opacity: 0, y: -10, x: "-50%" }}
|
||||
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
||||
exit={{ opacity: 0, y: -10, x: "-50%", transition: { duration: 0.15 } }}
|
||||
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
className="fixed top-4 left-1/2 z-50 transform -translate-x-1/2 will-change-transform"
|
||||
>
|
||||
<ExpandableTabs
|
||||
tabs={tabs}
|
||||
activeTab={navLinks.findIndex(link => link.href === activeLink)}
|
||||
onChange={(index) => {
|
||||
if (index !== null) {
|
||||
const href = navLinks[index].href;
|
||||
setActiveLink(href);
|
||||
handleNavClick(href);
|
||||
}
|
||||
}}
|
||||
trailingElement={
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch />
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleDashboardRedirect}
|
||||
className="px-5 py-2.5 text-sm font-medium bg-primary text-white rounded-full hover:bg-primary-dark transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-5 py-2.5 text-sm font-medium border-2 border-primary text-primary rounded-full hover:bg-primary hover:text-white transition-all duration-300"
|
||||
>
|
||||
Keluar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsLoginModalOpen(true)}
|
||||
className="px-6 py-2.5 text-sm font-medium bg-[#191970] text-white rounded-full hover:bg-blue-900 transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
Masuk
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.nav
|
||||
key="navbar"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10, transition: { duration: 0.15 } }}
|
||||
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
className="fixed top-0 left-0 right-0 z-50 transition-colors duration-300 bg-white/50 backdrop-blur-md border-b border-white/20 shadow-sm will-change-transform"
|
||||
>
|
||||
<div className="w-full px-8 lg:px-12">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
{/* Logo Section - Left */}
|
||||
<Link to="/" className="flex items-center space-x-2 cursor-default">
|
||||
<img
|
||||
src="/logo_polije.png"
|
||||
alt="Logo Polije"
|
||||
className="h-12 w-auto object-contain"
|
||||
<motion.nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
isScrolled
|
||||
? 'bg-white/95 backdrop-blur-md shadow-soft border-b border-gray-100'
|
||||
: 'bg-white shadow-sm'
|
||||
}`}
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center space-x-3 group"
|
||||
onClick={() => handleNavClick('#hero')}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 bg-white rounded-xl flex items-center justify-center shadow-soft group-hover:shadow-card transition-all duration-300 border border-primary/20">
|
||||
<img
|
||||
src="/logo_polijecare.png"
|
||||
alt="PolijeCare Logo"
|
||||
className="w-10 h-10 object-contain"
|
||||
/>
|
||||
<img
|
||||
src="/logo_polijecare.png"
|
||||
alt="Polijecare Logo"
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Centered Navigation Links */}
|
||||
<div className="hidden md:flex items-center space-x-1">
|
||||
{navLinks.map((link) => (
|
||||
<button
|
||||
key={link.name}
|
||||
onClick={() => {
|
||||
setActiveLink(link.href);
|
||||
handleNavClick(link.href);
|
||||
}}
|
||||
className={`px-5 py-2.5 text-base font-medium rounded-full transition-all duration-200 ${activeLink === link.href
|
||||
? 'bg-[#191970] text-white shadow-[0_4px_15px_rgba(25,25,112,0.4)]'
|
||||
: 'text-gray-600 hover:text-[#191970] hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-accent rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900 group-hover:text-primary transition-colors">
|
||||
Polijecare
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Right Section - Auth & Theme */}
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<Switch />
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleDashboardRedirect}
|
||||
className="px-5 py-2.5 bg-primary text-white rounded-full hover:bg-primary-dark transition-all duration-300 hover:shadow-lg font-medium text-sm"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-5 py-2.5 border-2 border-primary text-primary rounded-full hover:bg-primary hover:text-white transition-all duration-300 font-medium text-sm"
|
||||
>
|
||||
Keluar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsLoginModalOpen(true)}
|
||||
className="px-6 py-2.5 bg-[#191970] text-white rounded-full hover:bg-blue-900 transition-all duration-300 hover:shadow-lg font-medium text-sm"
|
||||
>
|
||||
Masuk
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="text-gray-600 hover:text-primary p-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-8">
|
||||
<motion.div
|
||||
className="flex space-x-6"
|
||||
variants={slideDown}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{navLinks.map((link, index) => (
|
||||
<motion.div
|
||||
key={link.name}
|
||||
variants={fadeIn}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<AnimatePresence mode="wait">
|
||||
{isMobileMenuOpen ? (
|
||||
<motion.path
|
||||
key="close"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
initial={{ opacity: 0, rotate: -90 }}
|
||||
animate={{ opacity: 1, rotate: 0 }}
|
||||
exit={{ opacity: 0, rotate: 90 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
) : (
|
||||
<motion.path
|
||||
key="menu"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{link.href.startsWith('#') ? (
|
||||
<button
|
||||
onClick={() => handleNavClick(link.href)}
|
||||
className="text-gray-600 hover:text-primary font-medium transition-colors relative group"
|
||||
>
|
||||
{link.name}
|
||||
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={link.href}
|
||||
className="text-gray-600 hover:text-primary font-medium transition-colors relative group"
|
||||
>
|
||||
{link.name}
|
||||
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
|
||||
</Link>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Auth Buttons & Theme Toggle */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleDashboardRedirect}
|
||||
className="px-4 py-2 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 hover:shadow-soft font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 border-2 border-primary text-primary rounded-xl hover:bg-primary hover:text-white transition-all duration-300 font-medium"
|
||||
>
|
||||
Keluar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 hover:shadow-soft font-medium"
|
||||
>
|
||||
Masuk
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
className="md:hidden absolute top-full left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="px-4 py-6 space-y-4">
|
||||
{navLinks.map((link, index) => (
|
||||
<motion.div
|
||||
key={link.name}
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ delay: 0.05 * index }}
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="text-gray-600 hover:text-primary p-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<AnimatePresence mode="wait">
|
||||
{isMobileMenuOpen ? (
|
||||
<motion.path
|
||||
key="close"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
initial={{ opacity: 0, rotate: -90 }}
|
||||
animate={{ opacity: 1, rotate: 0 }}
|
||||
exit={{ opacity: 0, rotate: 90 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
) : (
|
||||
<motion.path
|
||||
key="menu"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
className="md:hidden absolute top-full left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="px-4 py-6 space-y-4">
|
||||
{navLinks.map((link, index) => (
|
||||
<motion.div
|
||||
key={link.name}
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ delay: 0.05 * index }}
|
||||
>
|
||||
{link.href.startsWith('#') ? (
|
||||
<button
|
||||
onClick={() => handleNavClick(link.href)}
|
||||
className="block w-full text-left px-4 py-3 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
{link.href.startsWith('#') ? (
|
||||
<button
|
||||
onClick={() => handleNavClick(link.href)}
|
||||
className="block w-full text-left px-4 py-3 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={link.href}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block w-full px-4 py-3 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<div className="pt-4 border-t border-gray-100 dark:border-gray-700 space-y-3">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleDashboardRedirect}
|
||||
className="w-full px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-4 py-3 border-2 border-primary text-primary rounded-xl hover:bg-primary hover:text-white transition-all duration-300 font-medium"
|
||||
>
|
||||
Keluar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsLoginModalOpen(true);
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium"
|
||||
>
|
||||
Masuk
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.nav>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<LoginModal
|
||||
isOpen={isLoginModalOpen}
|
||||
onClose={() => setIsLoginModalOpen(false)}
|
||||
/>
|
||||
{link.name}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={link.href}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block w-full px-4 py-3 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<div className="pt-4 border-t border-gray-100 dark:border-gray-700 space-y-3">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleDashboardRedirect}
|
||||
className="w-full px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-4 py-3 border-2 border-primary text-primary rounded-xl hover:bg-primary hover:text-white transition-all duration-300 font-medium"
|
||||
>
|
||||
Keluar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block w-full px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-all duration-300 font-medium text-center"
|
||||
>
|
||||
Masuk
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const ProtectedRoute = ({ children, requiredRole }) => {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Show loading while checking auth
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Check role if specified
|
||||
if (requiredRole && user?.role !== requiredRole) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl text-red-500 mb-4">🚫</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Akses Ditolak</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Anda tidak memiliki izin untuk mengakses halaman ini.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
Kembali
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render children if authenticated and authorized
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
|
|
@ -2,119 +2,99 @@ import React from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { fadeIn, slideUp, staggerChildren } from '../utils/motionVariants';
|
||||
import {
|
||||
MessageCircle,
|
||||
FileText,
|
||||
Shield,
|
||||
Users,
|
||||
HeartHandshake,
|
||||
Phone,
|
||||
CheckCircle2,
|
||||
Search,
|
||||
Gavel,
|
||||
Smile
|
||||
} from 'lucide-react';
|
||||
import { SpotlightCard } from './ui/spotlight-card';
|
||||
import { FlowButton } from './ui/flow-button';
|
||||
|
||||
const Services = () => {
|
||||
const reportingMethods = [
|
||||
{
|
||||
id: 'whatsapp',
|
||||
title: 'Via WhatsApp',
|
||||
description: 'Layanan cepat tanggap untuk konsultasi awal dan pelaporan darurat. Terhubung langsung dengan tim satgas kami.',
|
||||
icon: <MessageCircle className="w-8 h-8" />,
|
||||
features: ['Respons 24/7', 'Konsultasi Privat', 'Pendampingan Awal'],
|
||||
buttonText: 'Chat WhatsApp Sekarang',
|
||||
buttonColor: 'bg-green-600 hover:bg-green-700',
|
||||
buttonLink: 'https://wa.me/6281234567890',
|
||||
gradient: 'from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20',
|
||||
iconColor: 'text-green-600 dark:text-green-400',
|
||||
spotlightColor: 'rgba(34, 197, 94, 0.2)' // Green glow for WhatsApp
|
||||
description: 'Laporkan secara langsung melalui WhatsApp untuk respons cepat dan konsultasi awal dengan tim kami.',
|
||||
icon: (
|
||||
<svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.149-.67.149-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414-.074-.123-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413"/>
|
||||
</svg>
|
||||
),
|
||||
features: ['Respons 24/7', 'Konsultasi awal', 'Bimbingan langkah selanjutnya'],
|
||||
buttonText: 'Butuh Bantuan Darurat',
|
||||
buttonColor: 'bg-accent hover:bg-accent-dark',
|
||||
buttonLink: 'https://wa.me/6281234567890'
|
||||
},
|
||||
{
|
||||
id: 'form',
|
||||
title: 'Form Pengaduan',
|
||||
description: 'Saluran resmi untuk pelaporan mendetail. Mendukung lampiran bukti dan kronologi lengkap untuk investigasi.',
|
||||
icon: <FileText className="w-8 h-8" />,
|
||||
features: ['Form Terstruktur', 'Upload Bukti Aman', 'Tracking Status'],
|
||||
buttonText: 'Isi Form Laporan',
|
||||
buttonColor: 'bg-[#191970] hover:bg-blue-900',
|
||||
buttonLink: '/artikel',
|
||||
gradient: 'from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20',
|
||||
iconColor: 'text-[#191970] dark:text-blue-400',
|
||||
spotlightColor: 'rgba(139, 92, 246, 0.2)' // Purple glow for Form
|
||||
}
|
||||
];
|
||||
|
||||
const handlingFlow = [
|
||||
{
|
||||
step: '01',
|
||||
title: 'Pelaporan',
|
||||
desc: 'Laporan masuk via WA atau Website.',
|
||||
icon: <FileText className="w-5 h-5" />
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Verifikasi',
|
||||
desc: 'Validasi data oleh tim Satgas.',
|
||||
icon: <CheckCircle2 className="w-5 h-5" />
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Tindak Lanjut',
|
||||
desc: 'Investigasi atau mediasi kasus.',
|
||||
icon: <Search className="w-5 h-5" />
|
||||
},
|
||||
{
|
||||
step: '04',
|
||||
title: 'Penyelesaian',
|
||||
desc: 'Pemulihan dan penutupan kasus.',
|
||||
icon: <Smile className="w-5 h-5" />
|
||||
title: 'Form Pengaduan Online',
|
||||
description: 'Isi form pengaduan secara online dengan detail lengkap dan upload bukti pendukung untuk proses yang lebih terstruktur.',
|
||||
icon: (
|
||||
<svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>
|
||||
<path d="M8 12h8v2H8zm0 4h8v2H8zm0-8h5v2H8z"/>
|
||||
</svg>
|
||||
),
|
||||
features: ['Form terstruktur', 'Upload bukti', 'Tracking status laporan'],
|
||||
buttonText: 'Laporkan Sekarang',
|
||||
buttonColor: 'bg-primary hover:bg-primary-dark',
|
||||
buttonLink: '/artikel'
|
||||
}
|
||||
];
|
||||
|
||||
const importantInfo = [
|
||||
{
|
||||
icon: <Shield className="w-6 h-6" />,
|
||||
title: 'Dijamin Aman',
|
||||
description: 'Identitas pelapor dirahasiakan sepenuhnya sesuai kode etik.'
|
||||
icon: '🛡️',
|
||||
title: 'Aman',
|
||||
description: 'Identitas Anda akan dirahasiakan sepenuhnya'
|
||||
},
|
||||
{
|
||||
icon: <Users className="w-6 h-6" />,
|
||||
icon: '👥',
|
||||
title: 'Profesional',
|
||||
description: 'Ditangani oleh tim ahli yang berpengalaman dan objektif.'
|
||||
description: 'Ditangani oleh tim yang berpengalaman'
|
||||
},
|
||||
{
|
||||
icon: <HeartHandshake className="w-6 h-6" />,
|
||||
title: 'Pendampingan',
|
||||
description: 'Dukungan psikologis dan hukum selama proses berjalan.'
|
||||
icon: '💚',
|
||||
title: 'Support',
|
||||
description: 'Dapatkan pendampingan penuh dari kami'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="services" className="pt-20 pb-24 bg-gray-50 dark:bg-gray-900 relative overflow-hidden transition-colors duration-300">
|
||||
{/* Background Decorations */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-[-10%] right-[-5%] w-96 h-96 bg-purple-200/30 dark:bg-purple-900/10 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-[-10%] left-[-5%] w-96 h-96 bg-blue-200/30 dark:bg-blue-900/10 rounded-full blur-3xl"></div>
|
||||
<section id="services" className="py-20 bg-gray-50 relative overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute top-20 left-20 w-72 h-72 bg-accent rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-primary rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
className="text-center mb-20"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<motion.h2
|
||||
className="text-4xl md:text-5xl font-bold text-gray-900 mb-6"
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
Cara <span className="text-primary">Melapor</span>
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
className="text-xl text-gray-600 max-w-3xl mx-auto leading-relaxed"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
Pilih metode pelaporan yang paling nyaman untuk Anda. Kami siap membantu dengan profesionalisme dan kerahasiaan terjamin.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-6 tracking-tight">
|
||||
Cara <span className="text-[#191970] dark:text-blue-400">Melapor</span>
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto leading-relaxed">
|
||||
Kami menyediakan ruang aman bagi Anda untuk bersuara. Pilih metode yang paling nyaman, kami siap mendampingi setiap langkahnya.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reporting Methods Grid */}
|
||||
<motion.div
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-24"
|
||||
{/* Reporting Methods Cards */}
|
||||
<motion.div
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-20"
|
||||
variants={staggerChildren}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
|
|
@ -123,175 +103,145 @@ const Services = () => {
|
|||
{reportingMethods.map((method, index) => (
|
||||
<motion.div
|
||||
key={method.id}
|
||||
className="bg-white rounded-3xl p-8 shadow-soft hover:shadow-card transition-all duration-500 hover:-translate-y-2 group"
|
||||
variants={fadeIn}
|
||||
whileHover={{ y: -5 }}
|
||||
className="h-full"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 * index }}
|
||||
>
|
||||
<SpotlightCard
|
||||
className="h-full p-8 rounded-3xl border border-white/20 dark:border-white/10 bg-white/10 dark:bg-black/20 backdrop-blur-sm shadow-xl transition-all duration-300 group hover:bg-white/15 dark:hover:bg-white/5"
|
||||
spotlightColor={method.spotlightColor}
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
className="w-20 h-20 bg-gradient-to-br from-primary/10 to-accent/10 rounded-2xl flex items-center justify-center text-primary mb-6 group-hover:from-primary/20 group-hover:to-accent/20 transition-all duration-300"
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Gradient Blob Background */}
|
||||
<div className={`absolute top-0 right-0 w-64 h-64 bg-gradient-to-br ${method.gradient} rounded-full blur-3xl -z-10 opacity-30 group-hover:opacity-60 transition-opacity duration-500`}></div>
|
||||
{method.icon}
|
||||
</motion.div>
|
||||
|
||||
<div className="flex flex-col h-full justify-between relative z-10">
|
||||
<div>
|
||||
<div className={`w-16 h-16 rounded-2xl bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center mb-6 ${method.iconColor}`}>
|
||||
{method.icon}
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-2xl font-bold text-gray-900 group-hover:text-primary transition-colors">
|
||||
{method.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{method.description}
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-3">
|
||||
{method.features.map((feature, featureIndex) => (
|
||||
<div key={featureIndex} className="flex items-center space-x-3">
|
||||
<div className="w-2 h-2 bg-accent rounded-full flex-shrink-0"></div>
|
||||
<span className="text-gray-700 text-sm font-medium">{feature}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{method.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
|
||||
{method.description}
|
||||
</p>
|
||||
|
||||
<ul className="space-y-3 mb-8">
|
||||
{method.features.map((feature, i) => (
|
||||
<li key={i} className="flex items-center text-gray-700 dark:text-gray-300 text-sm font-medium">
|
||||
<CheckCircle2 className={`w-4 h-4 mr-2 ${method.iconColor}`} />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center mt-auto pt-6">
|
||||
<FlowButton
|
||||
text={method.buttonText}
|
||||
href={method.buttonLink}
|
||||
target={method.buttonLink.startsWith('http') ? "_blank" : undefined}
|
||||
colorStr={method.id === 'whatsapp' ? '#16a34a' : '#2563eb'}
|
||||
hoverColorStr={method.id === 'whatsapp' ? '#16a34a' : '#1e40af'}
|
||||
className="w-full max-w-[280px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
|
||||
{/* Button */}
|
||||
<div className="pt-6">
|
||||
{method.buttonLink.startsWith('http') ? (
|
||||
<a
|
||||
href={method.buttonLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center justify-center px-8 py-4 ${method.buttonColor} text-white rounded-xl transition-all duration-300 hover:shadow-lg hover:-translate-y-1 font-semibold w-full sm:w-auto shadow-soft`}
|
||||
>
|
||||
{method.buttonText}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
to={method.buttonLink}
|
||||
className={`inline-flex items-center justify-center px-8 py-4 ${method.buttonColor} text-white rounded-xl transition-all duration-300 hover:shadow-lg hover:-translate-y-1 font-semibold w-full sm:w-auto shadow-soft`}
|
||||
>
|
||||
{method.buttonText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Support & Flow Section */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||
|
||||
{/* Trust Indicators (Left) */}
|
||||
<motion.div
|
||||
className="xl:col-span-1 bg-white dark:bg-gray-800 rounded-3xl p-8 border border-gray-100 dark:border-gray-700 shadow-md h-full"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Kenapa Kami?</h3>
|
||||
<div className="space-y-6">
|
||||
{importantInfo.map((info, index) => (
|
||||
<div key={index} className="flex items-start space-x-4">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center text-[#191970] dark:text-blue-300 flex-shrink-0">
|
||||
{info.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white text-sm">{info.title}</h4>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-xs leading-relaxed mt-1">{info.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Process Flow (Right - Timeline) */}
|
||||
<SpotlightCard
|
||||
className="xl:col-span-2 relative overflow-hidden rounded-3xl p-8 md:p-10 border border-white/20 dark:border-white/10 bg-white/10 dark:bg-black/20 backdrop-blur-sm shadow-xl transition-all duration-300 group hover:bg-white/15 dark:hover:bg-white/5"
|
||||
spotlightColor="rgba(25, 25, 112, 0.1)"
|
||||
>
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-50 dark:bg-blue-900/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"></div>
|
||||
<div className="absolute bottom-0 left-0 w-48 h-48 bg-purple-50 dark:bg-purple-900/10 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2"></div>
|
||||
|
||||
<h3 className="text-2xl font-bold mb-12 relative z-10 text-gray-900 dark:text-white">Alur Penanganan</h3>
|
||||
|
||||
<div className="relative z-10 w-full">
|
||||
{/* Progress Line Background */}
|
||||
<div className="hidden md:block absolute top-[28px] left-0 w-full h-[2px] bg-gray-200/50 dark:bg-gray-700/50 rounded-full"></div>
|
||||
|
||||
{/* Animated Progress Line */}
|
||||
<motion.div
|
||||
className="hidden md:block absolute top-[28px] left-0 h-[2px] bg-[#191970] dark:bg-blue-500 origin-left z-0 rounded-full overflow-hidden"
|
||||
initial={{ scaleX: 0 }}
|
||||
whileInView={{ scaleX: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 1.5, ease: "easeInOut", delay: 0.2 }}
|
||||
>
|
||||
{/* Continuous Shimmer Animation */}
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 w-full h-full bg-gradient-to-r from-transparent via-white/50 to-transparent"
|
||||
animate={{ x: ['-100%', '100%'] }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 2,
|
||||
ease: "linear",
|
||||
repeatDelay: 0.5
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{handlingFlow.map((step, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="relative group/step pt-4 md:pt-0"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.2 + 0.3 }}
|
||||
>
|
||||
<div className="relative z-10 flex flex-col items-center text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-white dark:bg-gray-800 border-2 border-gray-100 dark:border-gray-700 flex items-center justify-center mb-4 text-[#191970] dark:text-blue-500 shadow-md group-hover/step:scale-110 group-hover/step:border-[#191970] dark:group-hover/step:border-blue-500 transition-all duration-300 relative bg-opacity-80 backdrop-blur-md">
|
||||
{step.icon}
|
||||
</div>
|
||||
<span className="text-xs font-bold font-mono text-[#191970] dark:text-blue-400 mb-2 opacity-80">{step.step}</span>
|
||||
<h4 className="font-bold text-gray-900 dark:text-white text-base mb-2">{step.title}</h4>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed font-medium">{step.desc}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Emergency Banner */}
|
||||
<motion.div
|
||||
className="mt-12 bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-900/30 rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between gap-6"
|
||||
{/* Important Information */}
|
||||
<motion.div
|
||||
className="bg-gradient-to-r from-purple-50 to-purple-100 rounded-3xl p-8 md:p-12"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
|
||||
<Phone className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900 dark:text-white">Butuh Bantuan Darurat?</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">Jangan ragu untuk menghubungi kami jika situasi mendesak.</p>
|
||||
</div>
|
||||
<div className="text-center mb-8">
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Penting Untuk Diketahui
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
Kami memastikan setiap proses pelaporan berjalan dengan aman, profesional, dan mendukung.
|
||||
</p>
|
||||
</div>
|
||||
<FlowButton
|
||||
text="Hubungi Sekarang"
|
||||
href="https://wa.me/6281234567890"
|
||||
target="_blank"
|
||||
colorStr="#dc2626"
|
||||
hoverColorStr="#dc2626"
|
||||
className="w-auto px-8"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-6"
|
||||
variants={staggerChildren}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
{importantInfo.map((info, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="text-center p-6 bg-white rounded-2xl shadow-soft hover:shadow-card transition-all duration-300"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<div className="text-4xl mb-4">{info.icon}</div>
|
||||
<h4 className="text-lg font-bold text-gray-900 mb-2">{info.title}</h4>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">{info.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Emergency Notice */}
|
||||
<motion.div
|
||||
className="mt-12 bg-gradient-to-r from-danger/10 to-danger/5 border border-danger/20 rounded-2xl p-6"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-danger/20 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-danger" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-1">Darurat? Hubungi Kami Sekarang</h4>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Jika Anda atau orang lain berada dalam situasi darurat, segera hubungi WhatsApp kami untuk respons cepat 24/7.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<a
|
||||
href="https://wa.me/6281234567890"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 bg-danger text-white rounded-xl hover:bg-danger-dark transition-all duration-300 font-semibold shadow-soft hover:shadow-lg"
|
||||
>
|
||||
Hubungi Darurat
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export const Component = ({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
size = "md",
|
||||
className = "",
|
||||
...props
|
||||
}) => {
|
||||
const sizes = {
|
||||
sm: "px-10 py-4 rounded-full text-base",
|
||||
md: "p-4 rounded-2xl",
|
||||
lg: "p-6 rounded-3xl",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={`group relative overflow-hidden cursor-pointer transition-all duration-300 ease-out
|
||||
shadow-lg hover:shadow-xl hover:scale-[1.02] hover:-translate-y-1 active:scale-95
|
||||
border-0 flex items-center justify-between gap-6
|
||||
${sizes[size]}
|
||||
${className}`}>
|
||||
|
||||
{/* Subtle internal glow/shine for depth, but keeping base color solid */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000 ease-out"></div>
|
||||
|
||||
{/* Content - flat structure for perfect alignment */}
|
||||
<div className="relative z-10 flex items-center gap-3">
|
||||
{icon && React.cloneElement(icon, {
|
||||
className: "w-6 h-6 text-white/90 group-hover:text-white transition-colors shrink-0",
|
||||
})}
|
||||
<span className="text-white font-bold text-base whitespace-nowrap">{title}</span>
|
||||
</div>
|
||||
|
||||
{/* Arrow - Re-added as requested */}
|
||||
<div className="relative z-10 opacity-70 group-hover:opacity-100 group-hover:translate-x-1 transition-all duration-300">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
className="w-4 h-4 text-white">
|
||||
<path
|
||||
d="M9 5l7 7-7 7"
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
"use client";;
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Carousel, CarouselContent, CarouselItem } from "@/components/ui/carousel";
|
||||
|
||||
const data = [
|
||||
{
|
||||
id: "shadcn-ui",
|
||||
title: "shadcn/ui: Building a Modern Component Library",
|
||||
description:
|
||||
"Explore how shadcn/ui revolutionized React component libraries by providing a unique approach to component distribution and customization, making it easier for developers to build beautiful, accessible applications.",
|
||||
href: "https://ui.shadcn.com",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1551250928-243dc937c49d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w2NDI3NzN8MHwxfGFsbHwxMjN8fHx8fHwyfHwxNzIzODA2OTM5fA&ixlib=rb-4.0.3&q=80&w=1080",
|
||||
},
|
||||
{
|
||||
id: "tailwind",
|
||||
title: "Tailwind CSS: The Utility-First Revolution",
|
||||
description:
|
||||
"Discover how Tailwind CSS transformed the way developers style their applications, offering a utility-first approach that speeds up development while maintaining complete design flexibility.",
|
||||
href: "https://tailwindcss.com",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1551250928-e4a05afaed1e?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w2NDI3NzN8MHwxfGFsbHwxMjR8fHx8fHwyfHwxNzIzODA2OTM5fA&ixlib=rb-4.0.3&q=80&w=1080",
|
||||
},
|
||||
{
|
||||
id: "astro",
|
||||
title: "Astro: The All-in-One Web Framework",
|
||||
description:
|
||||
"Learn how Astro's innovative 'Islands Architecture' and zero-JS-by-default approach is helping developers build faster websites while maintaining rich interactivity where needed.",
|
||||
href: "https://astro.build",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1536735561749-fc87494598cb?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w2NDI3NzN8MHwxfGFsbHwxNzd8fHx8fHwyfHwxNzIzNjM0NDc0fA&ixlib=rb-4.0.3&q=80&w=1080",
|
||||
},
|
||||
{
|
||||
id: "react",
|
||||
title: "React: Pioneering Component-Based UI",
|
||||
description:
|
||||
"See how React continues to shape modern web development with its component-based architecture, enabling developers to build complex user interfaces with reusable, maintainable code.",
|
||||
href: "https://react.dev",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1548324215-9133768e4094?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w2NDI3NzN8MHwxfGFsbHwxMzF8fHx8fHwyfHwxNzIzNDM1MzA1fA&ixlib=rb-4.0.3&q=80&w=1080",
|
||||
},
|
||||
{
|
||||
id: "nextjs",
|
||||
title: "Next.js: The React Framework for Production",
|
||||
description:
|
||||
"Explore how Next.js has become the go-to framework for building full-stack React applications, offering features like server components, file-based routing, and automatic optimization.",
|
||||
href: "https://nextjs.org",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1550070881-a5d71eda5800?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w2NDI3NzN8MHwxfGFsbHwxMjV8fHx8fHwyfHwxNzIzNDM1Mjk4fA&ixlib=rb-4.0.3&q=80&w=1080",
|
||||
},
|
||||
];
|
||||
|
||||
const Gallery4 = ({
|
||||
title = "Case Studies",
|
||||
description = "Discover how leading companies and developers are leveraging modern web technologies to build exceptional digital experiences. These case studies showcase real-world applications and success stories.",
|
||||
items = data
|
||||
}) => {
|
||||
const [carouselApi, setCarouselApi] = useState();
|
||||
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = useState(false);
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!carouselApi) {
|
||||
return;
|
||||
}
|
||||
const updateSelection = () => {
|
||||
setCanScrollPrev(carouselApi.canScrollPrev());
|
||||
setCanScrollNext(carouselApi.canScrollNext());
|
||||
setCurrentSlide(carouselApi.selectedScrollSnap());
|
||||
};
|
||||
updateSelection();
|
||||
carouselApi.on("select", updateSelection);
|
||||
return () => {
|
||||
carouselApi.off("select", updateSelection);
|
||||
};
|
||||
}, [carouselApi]);
|
||||
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container mx-auto">
|
||||
<div className="mb-8 flex items-end justify-between md:mb-14 lg:mb-16">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-3xl font-medium md:text-4xl lg:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="max-w-lg text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="hidden shrink-0 gap-2 md:flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
carouselApi?.scrollPrev();
|
||||
}}
|
||||
disabled={!canScrollPrev}
|
||||
className="disabled:pointer-events-auto">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
carouselApi?.scrollNext();
|
||||
}}
|
||||
disabled={!canScrollNext}
|
||||
className="disabled:pointer-events-auto">
|
||||
<ArrowRight className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Carousel
|
||||
setApi={setCarouselApi}
|
||||
opts={{
|
||||
align: "start",
|
||||
loop: true,
|
||||
breakpoints: {
|
||||
"(max-width: 768px)": {
|
||||
dragFree: true,
|
||||
},
|
||||
},
|
||||
}}>
|
||||
<CarouselContent
|
||||
className="ml-0 2xl:ml-[max(8rem,calc(50vw-700px))] 2xl:mr-[max(0rem,calc(50vw-700px))]">
|
||||
{items.map((item) => (
|
||||
<CarouselItem key={item.id} className="max-w-[320px] pl-[20px] lg:max-w-[360px]">
|
||||
{item.href.startsWith('/') ? (
|
||||
<Link to={item.href} className="group rounded-xl">
|
||||
<div
|
||||
className="group relative h-full min-h-[27rem] max-w-full overflow-hidden rounded-xl md:aspect-[5/4] lg:aspect-[16/9]">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
className="absolute h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-105" />
|
||||
<div
|
||||
className="absolute inset-0 h-full bg-[linear-gradient(hsl(var(--primary)/0),hsl(var(--primary)/0.4),hsl(var(--primary)/0.8)_100%)] mix-blend-multiply" />
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 flex flex-col items-start p-6 text-primary-foreground md:p-8">
|
||||
<div className="mb-2 pt-4 text-xl font-semibold md:mb-3 md:pt-4 lg:pt-4">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mb-8 line-clamp-2 md:mb-12 lg:mb-9">
|
||||
{item.description}
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
Read more{" "}
|
||||
<ArrowRight className="ml-2 size-5 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer" className="group rounded-xl">
|
||||
<div
|
||||
className="group relative h-full min-h-[27rem] max-w-full overflow-hidden rounded-xl md:aspect-[5/4] lg:aspect-[16/9]">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
className="absolute h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-105" />
|
||||
<div
|
||||
className="absolute inset-0 h-full bg-[linear-gradient(hsl(var(--primary)/0),hsl(var(--primary)/0.4),hsl(var(--primary)/0.8)_100%)] mix-blend-multiply" />
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 flex flex-col items-start p-6 text-primary-foreground md:p-8">
|
||||
<div className="mb-2 pt-4 text-xl font-semibold md:mb-3 md:pt-4 lg:pt-4">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mb-8 line-clamp-2 md:mb-12 lg:mb-9">
|
||||
{item.description}
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
Read more{" "}
|
||||
<ArrowRight className="ml-2 size-5 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
<div className="mt-8 flex justify-center gap-2">
|
||||
{items.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`h-2 w-2 rounded-full transition-colors ${currentSlide === index ? "bg-primary" : "bg-primary/20"
|
||||
}`}
|
||||
onClick={() => carouselApi?.scrollTo(index)}
|
||||
aria-label={`Go to slide ${index + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Gallery4 };
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const Switch = () => {
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
|
||||
const handleChange = () => {
|
||||
toggleTheme();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<label className="theme-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="theme-switch__checkbox"
|
||||
checked={isDark}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="theme-switch__container">
|
||||
<div className="theme-switch__clouds" />
|
||||
<div className="theme-switch__stars-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 55" fill="none">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M135.831 3.00688C135.055 3.85027 134.111 4.29946 133 4.35447C134.111 4.40947 135.055 4.85867 135.831 5.71123C136.607 6.55462 136.996 7.56303 136.996 8.72727C136.996 7.95722 137.172 7.25134 137.525 6.59129C137.886 5.93124 138.372 5.39954 138.98 5.00535C139.598 4.60199 140.268 4.39114 141 4.35447C139.88 4.2903 138.936 3.85027 138.16 3.00688C137.384 2.16348 136.996 1.16425 136.996 0C136.996 1.16425 136.607 2.16348 135.831 3.00688ZM31 23.3545C32.1114 23.2995 33.0551 22.8503 33.8313 22.0069C34.6075 21.1635 34.9956 20.1642 34.9956 19C34.9956 20.1642 35.3837 21.1635 36.1599 22.0069C36.9361 22.8503 37.8798 23.2903 39 23.3545C38.2679 23.3911 37.5976 23.602 36.9802 24.0053C36.3716 24.3995 35.8864 24.9312 35.5248 25.5913C35.172 26.2513 34.9956 26.9572 34.9956 27.7273C34.9956 26.563 34.6075 25.5546 33.8313 24.7112C33.0551 23.8587 32.1114 23.4095 31 23.3545ZM0 36.3545C1.11136 36.2995 2.05513 35.8503 2.83131 35.0069C3.6075 34.1635 3.99559 33.1642 3.99559 32C3.99559 33.1642 4.38368 34.1635 5.15987 35.0069C5.93605 35.8503 6.87982 36.2903 8 36.3545C7.26792 36.3911 6.59757 36.602 5.98015 37.0053C5.37155 37.3995 4.88644 37.9312 4.52481 38.5913C4.172 39.2513 3.99559 39.9572 3.99559 40.7273C3.99559 39.563 3.6075 38.5546 2.83131 37.7112C2.05513 36.8587 1.11136 36.4095 0 36.3545ZM56.8313 24.0069C56.0551 24.8503 55.1114 25.2995 54 25.3545C55.1114 25.4095 56.0551 25.8587 56.8313 26.7112C57.6075 27.5546 57.9956 28.563 57.9956 29.7273C57.9956 28.9572 58.172 28.2513 58.5248 27.5913C58.8864 26.9312 59.3716 26.3995 59.9802 26.0053C60.5976 25.602 61.2679 25.3911 62 25.3545C60.8798 25.2903 59.9361 24.8503 59.1599 24.0069C58.3837 23.1635 57.9956 22.1642 57.9956 21C57.9956 22.1642 57.6075 23.1635 56.8313 24.0069ZM81 25.3545C82.1114 25.2995 83.0551 24.8503 83.8313 24.0069C84.6075 23.1635 84.9956 22.1642 84.9956 21C84.9956 22.1642 85.3837 23.1635 86.1599 24.0069C86.9361 24.8503 87.8798 25.2903 89 25.3545C88.2679 25.3911 87.5976 25.602 86.9802 26.0053C86.3716 26.3995 85.8864 26.9312 85.5248 27.5913C85.172 28.2513 84.9956 28.9572 84.9956 29.7273C84.9956 28.563 84.6075 27.5546 83.8313 26.7112C83.0551 25.8587 82.1114 25.4095 81 25.3545ZM136 36.3545C137.111 36.2995 138.055 35.8503 138.831 35.0069C139.607 34.1635 139.996 33.1642 139.996 32C139.996 33.1642 140.384 34.1635 141.16 35.0069C141.936 35.8503 142.88 36.2903 144 36.3545C143.268 36.3911 142.598 36.602 141.98 37.0053C141.372 37.3995 140.886 37.9312 140.525 38.5913C140.172 39.2513 139.996 39.9572 139.996 40.7273C139.996 39.563 139.607 38.5546 138.831 37.7112C138.055 36.8587 137.111 36.4095 136 36.3545ZM101.831 49.0069C101.055 49.8503 100.111 50.2995 99 50.3545C100.111 50.4095 101.055 50.8587 101.831 51.7112C102.607 52.5546 102.996 53.563 102.996 54.7273C102.996 53.9572 103.172 53.2513 103.525 52.5913C103.886 51.9312 104.372 51.3995 104.98 51.0053C105.598 50.602 106.268 50.3911 107 50.3545C105.88 50.2903 104.936 49.8503 104.16 49.0069C103.384 48.1635 102.996 47.1642 102.996 46C102.996 47.1642 102.607 48.1635 101.831 49.0069Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="theme-switch__circle-container">
|
||||
<div className="theme-switch__sun-moon-container">
|
||||
<div className="theme-switch__moon">
|
||||
<div className="theme-switch__spot" />
|
||||
<div className="theme-switch__spot" />
|
||||
<div className="theme-switch__spot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.theme-switch {
|
||||
--toggle-size: 7px;
|
||||
/* the size is adjusted using font-size,
|
||||
this is not transform scale,
|
||||
so you can choose any size */
|
||||
--container-width: 5.625em;
|
||||
--container-height: 2.5em;
|
||||
--container-radius: 6.25em;
|
||||
/* radius 0 - minecraft mode :) */
|
||||
--container-light-bg: #3D7EAE;
|
||||
--container-night-bg: #1D1F2C;
|
||||
--circle-container-diameter: 3.375em;
|
||||
--sun-moon-diameter: 2.125em;
|
||||
--sun-bg: #ECCA2F;
|
||||
--moon-bg: #C4C9D1;
|
||||
--spot-color: #959DB1;
|
||||
--circle-container-offset: calc((var(--circle-container-diameter) - var(--container-height)) / 2 * -1);
|
||||
--stars-color: #fff;
|
||||
--clouds-color: #F3FDFF;
|
||||
--back-clouds-color: #AACADF;
|
||||
--transition: .5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
--circle-transition: .3s cubic-bezier(0, -0.02, 0.35, 1.17);
|
||||
}
|
||||
|
||||
.theme-switch, .theme-switch *, .theme-switch *::before, .theme-switch *::after {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: var(--toggle-size);
|
||||
}
|
||||
|
||||
.theme-switch__container {
|
||||
width: var(--container-width);
|
||||
height: var(--container-height);
|
||||
background-color: var(--container-light-bg);
|
||||
border-radius: var(--container-radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
-webkit-box-shadow: 0em -0.062em 0.062em rgba(0, 0, 0, 0.25), 0em 0.062em 0.125em rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0em -0.062em 0.062em rgba(0, 0, 0, 0.25), 0em 0.062em 0.125em rgba(255, 255, 255, 0.94);
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-switch__container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
-webkit-box-shadow: 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset, 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset;
|
||||
box-shadow: 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset, 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset;
|
||||
border-radius: var(--container-radius)
|
||||
}
|
||||
|
||||
.theme-switch__checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-switch__circle-container {
|
||||
width: var(--circle-container-diameter);
|
||||
height: var(--circle-container-diameter);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
position: absolute;
|
||||
left: var(--circle-container-offset);
|
||||
top: var(--circle-container-offset);
|
||||
border-radius: var(--container-radius);
|
||||
-webkit-box-shadow: inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), 0 0 0 0.625em rgba(255, 255, 255, 0.1), 0 0 0 1.25em rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), 0 0 0 0.625em rgba(255, 255, 255, 0.1), 0 0 0 1.25em rgba(255, 255, 255, 0.1);
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-transition: var(--circle-transition);
|
||||
-o-transition: var(--circle-transition);
|
||||
transition: var(--circle-transition);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.theme-switch__sun-moon-container {
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: var(--sun-moon-diameter);
|
||||
height: var(--sun-moon-diameter);
|
||||
margin: auto;
|
||||
border-radius: var(--container-radius);
|
||||
background-color: var(--sun-bg);
|
||||
-webkit-box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #a1872a inset;
|
||||
box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #a1872a inset;
|
||||
-webkit-filter: drop-shadow(0.062em 0.125em 0.125em rgba(0, 0, 0, 0.25)) drop-shadow(0em 0.062em 0.125em rgba(0, 0, 0, 0.25));
|
||||
filter: drop-shadow(0.062em 0.125em 0.125em rgba(0, 0, 0, 0.25)) drop-shadow(0em 0.062em 0.125em rgba(0, 0, 0, 0.25));
|
||||
overflow: hidden;
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.theme-switch__moon {
|
||||
-webkit-transform: translateX(100%);
|
||||
-ms-transform: translateX(100%);
|
||||
transform: translateX(100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--moon-bg);
|
||||
border-radius: inherit;
|
||||
-webkit-box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #969696 inset;
|
||||
box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #969696 inset;
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-switch__spot {
|
||||
position: absolute;
|
||||
top: 0.75em;
|
||||
left: 0.312em;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
border-radius: var(--container-radius);
|
||||
background-color: var(--spot-color);
|
||||
-webkit-box-shadow: 0em 0.0312em 0.062em rgba(0, 0, 0, 0.25) inset;
|
||||
box-shadow: 0em 0.0312em 0.062em rgba(0, 0, 0, 0.25) inset;
|
||||
}
|
||||
|
||||
.theme-switch__spot:nth-of-type(2) {
|
||||
width: 0.375em;
|
||||
height: 0.375em;
|
||||
top: 0.937em;
|
||||
left: 1.375em;
|
||||
}
|
||||
|
||||
.theme-switch__spot:nth-last-of-type(3) {
|
||||
width: 0.25em;
|
||||
height: 0.25em;
|
||||
top: 0.312em;
|
||||
left: 0.812em;
|
||||
}
|
||||
|
||||
.theme-switch__clouds {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
background-color: var(--clouds-color);
|
||||
border-radius: var(--container-radius);
|
||||
position: absolute;
|
||||
bottom: -0.625em;
|
||||
left: 0.312em;
|
||||
-webkit-box-shadow: 0.937em 0.312em var(--clouds-color), -0.312em -0.312em var(--back-clouds-color), 1.437em 0.375em var(--clouds-color), 0.5em -0.125em var(--back-clouds-color), 2.187em 0 var(--clouds-color), 1.25em -0.062em var(--back-clouds-color), 2.937em 0.312em var(--clouds-color), 2em -0.312em var(--back-clouds-color), 3.625em -0.062em var(--clouds-color), 2.625em 0em var(--back-clouds-color), 4.5em -0.312em var(--clouds-color), 3.375em -0.437em var(--back-clouds-color), 4.625em -1.75em 0 0.437em var(--clouds-color), 4em -0.625em var(--back-clouds-color), 4.125em -2.125em 0 0.437em var(--back-clouds-color);
|
||||
box-shadow: 0.937em 0.312em var(--clouds-color), -0.312em -0.312em var(--back-clouds-color), 1.437em 0.375em var(--clouds-color), 0.5em -0.125em var(--back-clouds-color), 2.187em 0 var(--clouds-color), 1.25em -0.062em var(--back-clouds-color), 2.937em 0.312em var(--clouds-color), 2em -0.312em var(--back-clouds-color), 3.625em -0.062em var(--clouds-color), 2.625em 0em var(--back-clouds-color), 4.5em -0.312em var(--clouds-color), 3.375em -0.437em var(--back-clouds-color), 4.625em -1.75em 0 0.437em var(--clouds-color), 4em -0.625em var(--back-clouds-color), 4.125em -2.125em 0 0.437em var(--back-clouds-color);
|
||||
-webkit-transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
-o-transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
}
|
||||
|
||||
.theme-switch__stars-container {
|
||||
position: absolute;
|
||||
color: var(--stars-color);
|
||||
top: -100%;
|
||||
left: 0.312em;
|
||||
width: 2.75em;
|
||||
height: auto;
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
/* actions */
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container {
|
||||
background-color: var(--container-night-bg);
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__circle-container {
|
||||
left: calc(100% - var(--circle-container-offset) - var(--circle-container-diameter));
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__circle-container:hover {
|
||||
left: calc(100% - var(--circle-container-offset) - var(--circle-container-diameter) - 0.187em)
|
||||
}
|
||||
|
||||
.theme-switch__circle-container:hover {
|
||||
left: calc(var(--circle-container-offset) + 0.187em);
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__moon {
|
||||
-webkit-transform: translate(0);
|
||||
-ms-transform: translate(0);
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__clouds {
|
||||
bottom: -4.062em;
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__stars-container {
|
||||
top: 50%;
|
||||
-webkit-transform: translateY(-50%);
|
||||
-ms-transform: translateY(-50%);
|
||||
transform: translateY(-50%);
|
||||
}`;
|
||||
|
||||
export default Switch;
|
||||
|
|
@ -1,369 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { TimelineContent } from "@/components/ui/timeline-animation";
|
||||
import { VerticalCutReveal } from "@/components/ui/vertical-cut-reveal";
|
||||
import { ArrowRight, Shield, Heart, Lock, Scale, Phone } from "lucide-react";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useInView, motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export default function AboutSection() {
|
||||
const heroRef = useRef(null);
|
||||
const counterRef = useRef(null);
|
||||
const isCounterInView = useInView(counterRef, { once: true });
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
// Carousel state
|
||||
const heroImages = ["/Gambar1.jpg", "/Gambar2.jpeg", "/Gambar3.jpeg"];
|
||||
const [slideIndex, setSlideIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const SLIDE_DURATION = 5000; // 5 seconds
|
||||
|
||||
useEffect(() => {
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 100) return 0;
|
||||
return prev + (100 / (SLIDE_DURATION / 50));
|
||||
});
|
||||
}, 50);
|
||||
const slideTimer = setInterval(() => {
|
||||
setSlideIndex((prev) => (prev + 1) % heroImages.length);
|
||||
setProgress(0);
|
||||
}, SLIDE_DURATION);
|
||||
return () => {
|
||||
clearInterval(progressInterval);
|
||||
clearInterval(slideTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCounterInView) return;
|
||||
let start = 0;
|
||||
const end = 100;
|
||||
const duration = 2000;
|
||||
const stepTime = duration / end;
|
||||
const timer = setInterval(() => {
|
||||
start += 1;
|
||||
setCount(start);
|
||||
if (start >= end) clearInterval(timer);
|
||||
}, stepTime);
|
||||
return () => clearInterval(timer);
|
||||
}, [isCounterInView]);
|
||||
|
||||
const revealVariants = {
|
||||
visible: (i) => ({
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
delay: i * 0.15,
|
||||
duration: 0.3,
|
||||
},
|
||||
}),
|
||||
hidden: {
|
||||
filter: "blur(10px)",
|
||||
y: -20,
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const scaleVariants = {
|
||||
visible: (i) => ({
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
delay: i * 0.15,
|
||||
duration: 0.3,
|
||||
},
|
||||
}),
|
||||
hidden: {
|
||||
filter: "blur(10px)",
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="min-h-screen py-16 bg-[#f9f9f9] flex items-center" ref={heroRef} id="about">
|
||||
<div className="max-w-[90rem] mx-auto w-full px-8 lg:px-12">
|
||||
<div className="relative">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8 w-[85%] absolute lg:top-4 md:top-0 sm:-top-2 -top-3 z-10">
|
||||
<div className="flex items-center gap-2 text-xl">
|
||||
<span className="text-[#191970] animate-spin">✱</span>
|
||||
<TimelineContent
|
||||
as="span"
|
||||
animationNum={0}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="text-sm font-medium text-gray-600"
|
||||
>
|
||||
TENTANG KAMI
|
||||
</TimelineContent>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={0}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="md:w-8 md:h-8 sm:w-6 w-5 sm:h-6 h-5 border border-indigo-200 bg-indigo-50 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<Shield className="w-4 h-4 text-[#191970]" />
|
||||
</TimelineContent>
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={1}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="md:w-8 md:h-8 sm:w-6 w-5 sm:h-6 h-5 border border-indigo-200 bg-indigo-50 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<Heart className="w-4 h-4 text-[#191970]" />
|
||||
</TimelineContent>
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={2}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="md:w-8 md:h-8 sm:w-6 w-5 sm:h-6 h-5 border border-indigo-200 bg-indigo-50 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<Lock className="w-4 h-4 text-[#191970]" />
|
||||
</TimelineContent>
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={3}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="md:w-8 md:h-8 sm:w-6 w-5 sm:h-6 h-5 border border-indigo-200 bg-indigo-50 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<Scale className="w-4 h-4 text-[#191970]" />
|
||||
</TimelineContent>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Image with clip path */}
|
||||
<TimelineContent
|
||||
as="figure"
|
||||
animationNum={4}
|
||||
timelineRef={heroRef}
|
||||
customVariants={scaleVariants}
|
||||
className="relative group"
|
||||
>
|
||||
<svg
|
||||
className="w-full"
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
viewBox="0 0 100 40"
|
||||
>
|
||||
<defs>
|
||||
<clipPath
|
||||
id="clip-inverted"
|
||||
clipPathUnits={"userSpaceOnUse"}
|
||||
>
|
||||
<path
|
||||
transform="scale(100, 40)"
|
||||
d="M0.0998072 1H0.422076H0.749756C0.767072 1 0.774207 0.961783 0.77561 0.942675V0.807325C0.777053 0.743631 0.791844 0.731953 0.799059 0.734076H0.969813C0.996268 0.730255 1.00088 0.693206 0.999875 0.675159V0.0700637C0.999875 0.0254777 0.985045 0.00477707 0.977629 0H0.902473C0.854975 0 0.890448 0.138535 0.850165 0.138535H0.0204424C0.00408849 0.142357 0 0.180467 0 0.199045V0.410828C0 0.449045 0.0136283 0.46603 0.0204424 0.469745H0.0523086C0.0696245 0.471019 0.0735527 0.497877 0.0733523 0.511146V0.915605C0.0723903 0.983121 0.090588 1 0.0998072 1Z"
|
||||
fill="#D9D9D9"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#clip-inverted)">
|
||||
<AnimatePresence>
|
||||
<motion.image
|
||||
key={slideIndex}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
xlinkHref={heroImages[slideIndex]}
|
||||
initial={{ x: 100 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -100 }}
|
||||
transition={{ duration: 0.6, ease: "easeInOut" }}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</g>
|
||||
</svg>
|
||||
{/* Slide progress indicators */}
|
||||
<div className="absolute bottom-[3%] left-[10%] flex gap-3 items-center z-10">
|
||||
{heroImages.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[6px] rounded-full overflow-hidden cursor-pointer shadow-sm transition-all duration-300"
|
||||
style={{
|
||||
width: i === slideIndex ? '3rem' : '1.5rem',
|
||||
backgroundColor: 'rgba(255,255,255,0.4)'
|
||||
}}
|
||||
onClick={() => { setSlideIndex(i); setProgress(0); }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-100 ease-linear"
|
||||
style={{
|
||||
width: i === slideIndex ? `${progress}%` : i < slideIndex ? '100%' : '0%',
|
||||
backgroundColor: '#fff',
|
||||
boxShadow: '0 0 4px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 100% stat in the white cutout */}
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={5}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="absolute bottom-[10%] right-[0.5%] text-right"
|
||||
>
|
||||
<div className="flex items-baseline gap-2 justify-end" ref={counterRef}>
|
||||
<span className="text-[#191970] font-extrabold text-2xl sm:text-3xl lg:text-4xl leading-none" style={{ fontFamily: "'Inter', sans-serif" }}>{count}%</span>
|
||||
<span className="text-gray-500 text-2xl sm:text-3xl lg:text-4xl uppercase tracking-wider font-normal leading-none">Rahasia</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-xs sm:text-sm mt-0.5 block text-left">kerahasiaan terjamin</span>
|
||||
</TimelineContent>
|
||||
</TimelineContent>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap items-center justify-start gap-6 py-4 text-sm">
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={5}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="flex items-center gap-2 sm:text-base text-xs"
|
||||
>
|
||||
<span className="text-[#191970] font-bold">24/7</span>
|
||||
<span className="text-gray-600">layanan aktif</span>
|
||||
</TimelineContent>
|
||||
<span className="text-gray-300">|</span>
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={7}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="flex items-center gap-2 sm:text-base text-xs"
|
||||
>
|
||||
<span className="text-[#191970] font-bold">Gratis</span>
|
||||
<span className="text-gray-600">tanpa biaya</span>
|
||||
</TimelineContent>
|
||||
<span className="text-gray-300">|</span>
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={8}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="flex items-center gap-2 sm:text-base text-xs"
|
||||
>
|
||||
<span className="text-[#191970] font-bold">Cepat</span>
|
||||
<span className="text-gray-600">respon tanggap</span>
|
||||
</TimelineContent>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="md:col-span-2">
|
||||
<h2 className="sm:text-4xl md:text-5xl text-2xl !leading-[110%] font-semibold text-gray-900 mb-8">
|
||||
<VerticalCutReveal
|
||||
splitBy="words"
|
||||
staggerDuration={0.1}
|
||||
staggerFrom="first"
|
||||
reverse={true}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 250,
|
||||
damping: 30,
|
||||
delay: 1,
|
||||
}}
|
||||
>
|
||||
Menciptakan Kampus Aman & Bermartabat.
|
||||
</VerticalCutReveal>
|
||||
</h2>
|
||||
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={9}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="grid md:grid-cols-2 gap-8 text-gray-600"
|
||||
>
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={10}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="sm:text-base text-xs"
|
||||
>
|
||||
<p className="leading-relaxed text-justify">
|
||||
PolijeCare merupakan kanal resmi pengaduan Satgas Pencegahan
|
||||
dan Penanganan Kekerasan Seksual (PPKPT) Politeknik Negeri
|
||||
Jember. Kami hadir sebagai garda terdepan dalam melindungi
|
||||
korban dan memberikan pendampingan profesional.
|
||||
</p>
|
||||
</TimelineContent>
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={11}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="sm:text-base text-xs"
|
||||
>
|
||||
<p className="leading-relaxed text-justify">
|
||||
Setiap laporan ditangani secara empatik, profesional, dan
|
||||
menjaga kerahasiaan penuh. Kami percaya bahwa setiap individu
|
||||
berhak mendapat rasa aman dalam menempuh pendidikan tanpa
|
||||
ancaman kekerasan seksual.
|
||||
</p>
|
||||
</TimelineContent>
|
||||
</TimelineContent>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<div className="text-right">
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={12}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="text-[#191970] text-2xl font-bold mb-2"
|
||||
>
|
||||
SATGAS PPKPT
|
||||
</TimelineContent>
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={13}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="text-gray-600 text-sm mb-8"
|
||||
>
|
||||
Politeknik Negeri Jember
|
||||
</TimelineContent>
|
||||
|
||||
<TimelineContent
|
||||
as="div"
|
||||
animationNum={14}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
className="mb-6"
|
||||
>
|
||||
<p className="text-gray-900 font-medium mb-4">
|
||||
Siap untuk melaporkan atau butuh bantuan? Kami siap mendengarkan Anda.
|
||||
</p>
|
||||
</TimelineContent>
|
||||
|
||||
<TimelineContent
|
||||
as="a"
|
||||
animationNum={15}
|
||||
timelineRef={heroRef}
|
||||
customVariants={revealVariants}
|
||||
href="#contact"
|
||||
className="group relative inline-flex w-fit ml-auto items-center gap-2 px-7 py-3 bg-[#191970] text-white rounded-full font-semibold text-sm cursor-pointer transition-all duration-300 hover:bg-[#1a237e] hover:text-white hover:shadow-[0_4px_20px_rgba(26,35,126,0.5)] hover:gap-3"
|
||||
>
|
||||
<Phone className="w-4 h-4 opacity-60" />
|
||||
HUBUNGI KAMI
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</TimelineContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props} />
|
||||
);
|
||||
})
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||
{...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
import * as React from "react"
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const CarouselContext = React.createContext(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef((
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel({
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
}, plugins)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback((event) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
}, [scrollPrev, scrollNext])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
};
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
})
|
||||
Carousel.displayName = "Carousel"
|
||||
|
||||
const CarouselContent = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
|
||||
const CarouselItem = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
})
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
|
||||
const CarouselPrevious = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", className)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
|
||||
const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"use client";;
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useOnClickOutside } from "usehooks-ts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = {
|
||||
initial: {
|
||||
gap: 0,
|
||||
paddingLeft: ".5rem",
|
||||
paddingRight: ".5rem",
|
||||
},
|
||||
animate: (isExpanded) => ({
|
||||
gap: isExpanded ? ".5rem" : 0,
|
||||
paddingLeft: isExpanded ? "2rem" : "1rem",
|
||||
paddingRight: isExpanded ? "2rem" : "1rem",
|
||||
}),
|
||||
};
|
||||
|
||||
const spanVariants = {
|
||||
initial: { width: 0, opacity: 0 },
|
||||
animate: { width: "auto", opacity: 1 },
|
||||
exit: { width: 0, opacity: 0 },
|
||||
};
|
||||
|
||||
const transition = { type: "spring", bounce: 0, duration: 0.3 };
|
||||
|
||||
export function ExpandableTabs({
|
||||
tabs,
|
||||
className,
|
||||
activeColor = "text-primary",
|
||||
onChange,
|
||||
activeTab,
|
||||
trailingElement
|
||||
}) {
|
||||
const [selected, setSelected] = React.useState(null);
|
||||
const [hovered, setHovered] = React.useState(null);
|
||||
const outsideClickRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeTab !== undefined && activeTab !== null) {
|
||||
setSelected(activeTab);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
useOnClickOutside(outsideClickRef, () => {
|
||||
// Keep the active tab visible — don't clear selection on outside click
|
||||
});
|
||||
|
||||
const handleSelect = (index) => {
|
||||
setSelected(index);
|
||||
onChange?.(index);
|
||||
};
|
||||
|
||||
const Separator = () => (
|
||||
<div className="mx-1 h-[24px] w-[1.2px] bg-border" aria-hidden="true" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outsideClickRef}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2 rounded-full border border-white/20 bg-white/80 backdrop-blur-md p-1 shadow-lg",
|
||||
className
|
||||
)}>
|
||||
{tabs.map((tab, index) => {
|
||||
if (tab.type === "separator") {
|
||||
return <Separator key={`separator-${index}`} />;
|
||||
}
|
||||
|
||||
const Icon = tab.icon;
|
||||
const isExpanded = selected === index || hovered === index;
|
||||
return (
|
||||
<motion.button
|
||||
key={tab.title}
|
||||
layout
|
||||
variants={buttonVariants}
|
||||
initial={false}
|
||||
animate="animate"
|
||||
custom={isExpanded}
|
||||
onClick={() => handleSelect(index)}
|
||||
onMouseEnter={() => setHovered(index)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
transition={transition}
|
||||
className={cn(
|
||||
"relative flex items-center rounded-full py-3 text-base font-medium transition-colors duration-300",
|
||||
selected === index
|
||||
? "bg-[#191970] text-white shadow-[0_4px_15px_rgba(25,25,112,0.4)]"
|
||||
: hovered === index
|
||||
? "bg-gray-200 text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}>
|
||||
<Icon size={24} />
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.span
|
||||
variants={spanVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={transition}
|
||||
className="overflow-hidden whitespace-nowrap">
|
||||
{tab.title}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
{trailingElement && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="pl-1">
|
||||
{trailingElement}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
// This is file of your component
|
||||
|
||||
// You can use any dependencies from npm; we import them automatically in package.json
|
||||
'use client';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
export function FlowButton({
|
||||
text = "Button",
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
className = "",
|
||||
colorStr = "#111111", // Default text/border color
|
||||
hoverColorStr = "#111111", // Circle bg color on hover
|
||||
}) {
|
||||
const Component = href ? 'a' : 'button';
|
||||
|
||||
return (
|
||||
<Component
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
className={`group relative flex items-center justify-center gap-1 overflow-hidden rounded-[100px] border-[1.5px] bg-transparent px-8 py-3 text-sm font-semibold cursor-pointer transition-all duration-[600ms] ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-transparent hover:!text-white hover:rounded-[12px] active:scale-[0.95] ${className}`}
|
||||
style={{
|
||||
borderColor: `${colorStr}40`, // 40 is hex opacity
|
||||
color: colorStr
|
||||
}}
|
||||
>
|
||||
{/* Left arrow */}
|
||||
<ArrowRight
|
||||
className="absolute w-4 h-4 left-[-25%] fill-none z-[9] group-hover:left-4 group-hover:!stroke-white transition-all duration-[800ms] ease-[cubic-bezier(0.34,1.56,0.64,1)]"
|
||||
style={{ stroke: colorStr }}
|
||||
/>
|
||||
|
||||
{/* Text */}
|
||||
<span className="relative z-[1] -translate-x-3 group-hover:translate-x-3 transition-all duration-[800ms] ease-out group-hover:!text-white">
|
||||
{text}
|
||||
</span>
|
||||
|
||||
{/* Circle Hover Effect */}
|
||||
<span
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-4 rounded-[50%] opacity-0 group-hover:w-[1200px] group-hover:h-[1200px] group-hover:opacity-100 transition-all duration-[800ms] ease-[cubic-bezier(0.19,1,0.22,1)]"
|
||||
style={{ backgroundColor: hoverColorStr }}
|
||||
></span>
|
||||
|
||||
{/* Right arrow */}
|
||||
<ArrowRight
|
||||
className="absolute w-4 h-4 right-4 fill-none z-[9] group-hover:right-[-25%] group-hover:!stroke-white transition-all duration-[800ms] ease-[cubic-bezier(0.34,1.56,0.64,1)]"
|
||||
style={{ stroke: colorStr }}
|
||||
/>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props} />
|
||||
);
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence, useScroll, useMotionValueEvent } from "framer-motion";
|
||||
import { Plus, Heart, Star } from "lucide-react";
|
||||
|
||||
export function ScrollParticles() {
|
||||
const { scrollY } = useScroll();
|
||||
const [particles, setParticles] = useState([]);
|
||||
const [windowHeight, setWindowHeight] = useState(0);
|
||||
const lastY = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setWindowHeight(window.innerHeight);
|
||||
const handleResize = () => setWindowHeight(window.innerHeight);
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useMotionValueEvent(scrollY, "change", (latest) => {
|
||||
const diff = Math.abs(latest - lastY.current);
|
||||
const direction = latest > lastY.current ? 1 : -1;
|
||||
|
||||
// Spawn particles if scrolling fast enough
|
||||
if (diff > 5 && windowHeight > 0) {
|
||||
const scrollHeight = document.documentElement.scrollHeight - windowHeight;
|
||||
const progress = latest / scrollHeight;
|
||||
|
||||
// Calculate approximate scrollbar thumb position (center of thumb)
|
||||
const thumbY = progress * (windowHeight - 40);
|
||||
|
||||
const shapes = ["plus", "heart", "star"];
|
||||
const randomShape = shapes[Math.floor(Math.random() * shapes.length)];
|
||||
|
||||
const newParticle = {
|
||||
id: Date.now() + Math.random(),
|
||||
top: thumbY + (Math.random() * 40 - 20), // Wider spread
|
||||
left: -Math.random() * 20 - 10,
|
||||
size: Math.random() * 14 + 10, // Larger size: 10px - 24px
|
||||
color: Math.random() > 0.5 ? "#191970" : "#4C6EF5",
|
||||
shape: randomShape,
|
||||
rotation: Math.random() * 360,
|
||||
velocity: {
|
||||
x: -Math.random() * 30 - 20, // Faster drift left
|
||||
y: (Math.random() * 30 - 15) + (direction * 8)
|
||||
}
|
||||
};
|
||||
|
||||
setParticles(prev => [...prev.slice(-20), newParticle]);
|
||||
}
|
||||
|
||||
lastY.current = latest;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 right-0 w-4 h-full pointer-events-none z-[9999]">
|
||||
<AnimatePresence>
|
||||
{particles.map((particle) => (
|
||||
<motion.div
|
||||
key={particle.id}
|
||||
initial={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: particle.top,
|
||||
scale: 0,
|
||||
rotate: particle.rotation
|
||||
}}
|
||||
animate={{
|
||||
opacity: 0,
|
||||
x: particle.velocity.x,
|
||||
y: particle.top + particle.velocity.y,
|
||||
scale: 1,
|
||||
rotate: particle.rotation + 180
|
||||
}}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className="absolute right-1 flex items-center justify-center"
|
||||
style={{
|
||||
width: particle.size,
|
||||
height: particle.size,
|
||||
color: particle.color,
|
||||
}}
|
||||
>
|
||||
{particle.shape === "plus" && <Plus size={particle.size} strokeWidth={3} />}
|
||||
{particle.shape === "heart" && <Heart size={particle.size} fill={particle.color} strokeWidth={0} />}
|
||||
{particle.shape === "star" && <Star size={particle.size} fill={particle.color} strokeWidth={0} />}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn("p-1", position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props} />
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import React, { useRef, useState } from "react";
|
||||
|
||||
export const SpotlightCard = ({ children, className = "", spotlightColor = "rgba(255, 255, 255, 0.25)" }) => {
|
||||
const divRef = useRef(null);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [opacity, setOpacity] = useState(0);
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!divRef.current) return;
|
||||
|
||||
const rect = divRef.current.getBoundingClientRect();
|
||||
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setOpacity(1);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setOpacity(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={`relative overflow-hidden rounded-3xl border border-gray-100 dark:border-gray-800 shadow-lg ${className}`}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute -inset-px transition duration-300"
|
||||
style={{
|
||||
opacity,
|
||||
background: `radial-gradient(600px circle at ${position.x}px ${position.y}px, ${spotlightColor}, transparent 40%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)} />
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useRef, createElement, forwardRef } from "react";
|
||||
import {
|
||||
motion,
|
||||
useInView,
|
||||
useScroll,
|
||||
useTransform,
|
||||
} from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TimelineContent = forwardRef(
|
||||
(
|
||||
{
|
||||
as = "div",
|
||||
children,
|
||||
className,
|
||||
animationNum = 0,
|
||||
timelineRef,
|
||||
customVariants,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const defaultVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
filter: "blur(8px)",
|
||||
},
|
||||
visible: (i) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
delay: i * 0.3,
|
||||
duration: 0.5,
|
||||
ease: "easeOut",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const variants = customVariants || defaultVariants;
|
||||
const isInView = useInView(timelineRef, { once: true, amount: 0.1 });
|
||||
const MotionComponent = motion[as] || motion.div;
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
variants={variants}
|
||||
custom={animationNum}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TimelineContent.displayName = "TimelineContent";
|
||||
|
||||
export { TimelineContent };
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const VerticalCutReveal = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
reverse = false,
|
||||
transition = {
|
||||
type: "spring",
|
||||
stiffness: 190,
|
||||
damping: 22,
|
||||
},
|
||||
splitBy = "words",
|
||||
staggerDuration = 0.2,
|
||||
staggerFrom = "first",
|
||||
containerClassName,
|
||||
wordLevelClassName,
|
||||
elementLevelClassName,
|
||||
onClick,
|
||||
onStart,
|
||||
onComplete,
|
||||
autoStart = true,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const containerRef = useRef(null);
|
||||
const text =
|
||||
typeof children === "string" ? children : children?.toString() || "";
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
const splitIntoCharacters = (text) => {
|
||||
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
|
||||
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
||||
return Array.from(segmenter.segment(text), ({ segment }) => segment);
|
||||
}
|
||||
return Array.from(text);
|
||||
};
|
||||
|
||||
const elements = useMemo(() => {
|
||||
const words = text.split(" ");
|
||||
if (splitBy === "characters") {
|
||||
return words.map((word, i) => ({
|
||||
characters: splitIntoCharacters(word),
|
||||
needsSpace: i !== words.length - 1,
|
||||
}));
|
||||
}
|
||||
return splitBy === "words"
|
||||
? text.split(" ")
|
||||
: splitBy === "lines"
|
||||
? text.split("\n")
|
||||
: text.split(splitBy);
|
||||
}, [text, splitBy]);
|
||||
|
||||
const getStaggerDelay = useCallback(
|
||||
(index) => {
|
||||
const total =
|
||||
splitBy === "characters"
|
||||
? elements.reduce(
|
||||
(acc, word) =>
|
||||
acc +
|
||||
(typeof word === "string"
|
||||
? 1
|
||||
: word.characters.length + (word.needsSpace ? 1 : 0)),
|
||||
0
|
||||
)
|
||||
: elements.length;
|
||||
if (staggerFrom === "first") return index * staggerDuration;
|
||||
if (staggerFrom === "last")
|
||||
return (total - 1 - index) * staggerDuration;
|
||||
if (staggerFrom === "center") {
|
||||
const center = Math.floor(total / 2);
|
||||
return Math.abs(center - index) * staggerDuration;
|
||||
}
|
||||
if (staggerFrom === "random") {
|
||||
const randomIndex = Math.floor(Math.random() * total);
|
||||
return Math.abs(randomIndex - index) * staggerDuration;
|
||||
}
|
||||
return Math.abs(staggerFrom - index) * staggerDuration;
|
||||
},
|
||||
[elements.length, staggerFrom, staggerDuration]
|
||||
);
|
||||
|
||||
const startAnimation = useCallback(() => {
|
||||
setIsAnimating(true);
|
||||
onStart?.();
|
||||
}, [onStart]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
startAnimation,
|
||||
reset: () => setIsAnimating(false),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (autoStart) {
|
||||
startAnimation();
|
||||
}
|
||||
}, [autoStart]);
|
||||
|
||||
const variants = {
|
||||
hidden: { y: reverse ? "-100%" : "100%" },
|
||||
visible: (i) => ({
|
||||
y: 0,
|
||||
transition: {
|
||||
...transition,
|
||||
delay: (transition?.delay || 0) + getStaggerDelay(i),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
containerClassName,
|
||||
"flex flex-wrap whitespace-pre-wrap",
|
||||
splitBy === "lines" && "flex-col"
|
||||
)}
|
||||
onClick={onClick}
|
||||
ref={containerRef}
|
||||
{...props}
|
||||
>
|
||||
<span className="sr-only">{text}</span>
|
||||
|
||||
{(splitBy === "characters"
|
||||
? elements
|
||||
: elements.map((el, i) => ({
|
||||
characters: [el],
|
||||
needsSpace: i !== elements.length - 1,
|
||||
}))
|
||||
).map((wordObj, wordIndex, array) => {
|
||||
const previousCharsCount = array
|
||||
.slice(0, wordIndex)
|
||||
.reduce((sum, word) => sum + word.characters.length, 0);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={wordIndex}
|
||||
aria-hidden="true"
|
||||
className={cn("inline-flex overflow-hidden", wordLevelClassName)}
|
||||
>
|
||||
{wordObj.characters.map((char, charIndex) => (
|
||||
<span
|
||||
className={cn(
|
||||
elementLevelClassName,
|
||||
"whitespace-pre-wrap relative"
|
||||
)}
|
||||
key={charIndex}
|
||||
>
|
||||
<motion.span
|
||||
custom={previousCharsCount + charIndex}
|
||||
initial="hidden"
|
||||
animate={isAnimating ? "visible" : "hidden"}
|
||||
variants={variants}
|
||||
onAnimationComplete={
|
||||
wordIndex === elements.length - 1 &&
|
||||
charIndex === wordObj.characters.length - 1
|
||||
? onComplete
|
||||
: undefined
|
||||
}
|
||||
className="inline-block"
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
{wordObj.needsSpace && <span> </span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
VerticalCutReveal.displayName = "VerticalCutReveal";
|
||||
|
||||
export { VerticalCutReveal };
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import axios from '../utils/axiosConfig';
|
||||
|
||||
// Initial state
|
||||
const initialState = {
|
||||
user: null,
|
||||
token: localStorage.getItem('token'),
|
||||
isAuthenticated: false,
|
||||
isLoading: false, // Changed to false initially
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Action types
|
||||
const AUTH_ACTIONS = {
|
||||
LOGIN_START: 'LOGIN_START',
|
||||
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
|
||||
LOGIN_FAILURE: 'LOGIN_FAILURE',
|
||||
LOGOUT: 'LOGOUT',
|
||||
LOAD_USER_START: 'LOAD_USER_START',
|
||||
LOAD_USER_SUCCESS: 'LOAD_USER_SUCCESS',
|
||||
LOAD_USER_FAILURE: 'LOAD_USER_FAILURE',
|
||||
CLEAR_ERROR: 'CLEAR_ERROR',
|
||||
};
|
||||
|
||||
// Reducer
|
||||
const authReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case AUTH_ACTIONS.LOGIN_START:
|
||||
return {
|
||||
...state,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.LOGIN_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.LOGIN_FAILURE:
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
error: action.payload,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.LOGOUT:
|
||||
return {
|
||||
...state,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.LOAD_USER_START:
|
||||
return {
|
||||
...state,
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.LOAD_USER_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
user: action.payload,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.LOAD_USER_FAILURE:
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.CLEAR_ERROR:
|
||||
return {
|
||||
...state,
|
||||
error: null,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Create context
|
||||
const AuthContext = createContext();
|
||||
|
||||
// Provider component
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
// Set axios default header
|
||||
useEffect(() => {
|
||||
if (state.token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${state.token}`;
|
||||
} else {
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
}
|
||||
}, [state.token]);
|
||||
|
||||
// Load user on mount
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (token) {
|
||||
// Set token immediately to prevent white screen
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
dispatch({ type: AUTH_ACTIONS.LOAD_USER_START });
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/user');
|
||||
if (response.data.success) {
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.LOAD_USER_SUCCESS,
|
||||
payload: response.data.user,
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: AUTH_ACTIONS.LOAD_USER_FAILURE });
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load user error:', error);
|
||||
dispatch({ type: AUTH_ACTIONS.LOAD_USER_FAILURE });
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
}
|
||||
} else {
|
||||
// No token - set loading to false immediately
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.LOAD_USER_SUCCESS,
|
||||
payload: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
// Login function
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
dispatch({ type: AUTH_ACTIONS.LOGIN_START });
|
||||
|
||||
console.log('🔍 Attempting login to:', '/api/login');
|
||||
console.log('🔍 Credentials:', { email: credentials.email, password: '***' });
|
||||
|
||||
const response = await axios.post('/api/login', credentials);
|
||||
|
||||
console.log('🔍 Auth response:', response.data);
|
||||
console.log('🔍 Response status:', response.status);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
const { token, user } = response.data;
|
||||
|
||||
// Store token
|
||||
localStorage.setItem('token', token);
|
||||
|
||||
// Set axios header
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.LOGIN_SUCCESS,
|
||||
payload: { token, user },
|
||||
});
|
||||
|
||||
return { success: true, data: response.data };
|
||||
} else {
|
||||
const message = response.data?.message || 'Login gagal';
|
||||
console.error('❌ Login failed:', message);
|
||||
return { success: false, message };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Auth error:', error);
|
||||
console.error('❌ Error config:', error.config);
|
||||
console.error('❌ Error response:', error.response);
|
||||
console.error('❌ Error status:', error.response?.status);
|
||||
console.error('❌ Error data:', error.response?.data);
|
||||
|
||||
// Handle different error types
|
||||
let errorMessage = 'Login gagal. Silakan coba lagi.';
|
||||
|
||||
if (error.code === 'ECONNREFUSED' || error.code === 'ERR_NETWORK') {
|
||||
errorMessage = 'Tidak dapat terhubung ke server. Pastikan backend berjalan.';
|
||||
} else if (error.response?.status === 401) {
|
||||
errorMessage = error.response.data?.message || 'Email atau password salah.';
|
||||
} else if (error.response?.status === 422) {
|
||||
errorMessage = 'Validasi gagal. Periksa kembali input Anda.';
|
||||
} else if (error.response?.data?.message) {
|
||||
errorMessage = error.response.data.message;
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.LOGIN_FAILURE,
|
||||
payload: errorMessage,
|
||||
});
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
// Logout function
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (state.token) {
|
||||
await axios.post('/api/logout');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
dispatch({ type: AUTH_ACTIONS.LOGOUT });
|
||||
}
|
||||
};
|
||||
|
||||
// Clear error
|
||||
const clearError = () => {
|
||||
dispatch({ type: AUTH_ACTIONS.CLEAR_ERROR });
|
||||
};
|
||||
|
||||
const value = {
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
clearError,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom hook to use auth context
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export default AuthContext;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
/* Tailwind CSS Directives - v3.4.1 */
|
||||
/* Tailwind CSS Directives - v3.4.1 */
|
||||
/* eslint-disable-next-line at-rule-no-unknown */
|
||||
@tailwind base;
|
||||
/* eslint-disable-next-line at-rule-no-unknown */
|
||||
|
|
@ -8,25 +8,16 @@
|
|||
|
||||
/* Custom CSS Variables and Overrides */
|
||||
:root {
|
||||
--color-primary: #e6e6fa;
|
||||
/* Refined Lavender/Purple for better visibility */
|
||||
--color-primary-light: #E9D5FF;
|
||||
/* Soft Lavender for backgrounds */
|
||||
--color-primary-dark: #805AD5;
|
||||
--color-accent: #4C6EF5;
|
||||
/* Professional Blue */
|
||||
--color-primary: #E6E6FA; /* Lavender */
|
||||
--color-primary-dark: #D8BFD8; /* Thistle */
|
||||
--color-accent: #4C6EF5; /* Professional Blue */
|
||||
--color-accent-light: #5C7CFA;
|
||||
--color-secondary: #495057;
|
||||
/* Professional Gray */
|
||||
--color-secondary: #495057; /* Professional Gray */
|
||||
--color-secondary-light: #6C757D;
|
||||
--color-danger: #dc2626;
|
||||
--color-danger-light: #ef4444;
|
||||
--color-white: #ffffff;
|
||||
--color-soft-white: #F8F9FA;
|
||||
/* Soft White Foundation */
|
||||
--color-gray-50: #F8F9FA;
|
||||
/* Matches soft white */
|
||||
--color-gray-100: #f3f4f6;
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f3f4f6;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d1d5db;
|
||||
|
|
@ -40,16 +31,13 @@
|
|||
|
||||
/* Dark Mode Variables */
|
||||
.dark {
|
||||
--color-primary: #D8BFD8;
|
||||
/* Darker Lavender */
|
||||
--color-primary: #D8BFD8; /* Darker Lavender */
|
||||
--color-primary-dark: #C8B2DB;
|
||||
--color-accent: #5C7CFA;
|
||||
/* Lighter Blue for dark mode */
|
||||
--color-accent: #5C7CFA; /* Lighter Blue for dark mode */
|
||||
--color-accent-light: #7C8FFA;
|
||||
--color-secondary: #6C757D;
|
||||
--color-secondary-light: #868E96;
|
||||
--color-white: #1f2937;
|
||||
/* Dark background */
|
||||
--color-white: #1f2937; /* Dark background */
|
||||
--color-gray-50: #374151;
|
||||
--color-gray-100: #4b5563;
|
||||
--color-gray-200: #6b7280;
|
||||
|
|
@ -290,31 +278,10 @@ body {
|
|||
.hover\:bg-green-600:hover,
|
||||
.hover\:bg-green-700:hover,
|
||||
.hover\:bg-green-800:hover,
|
||||
.hover\:bg-green-900:hover,
|
||||
.from-green-50,
|
||||
.from-green-100,
|
||||
.from-green-200,
|
||||
.from-green-300,
|
||||
.from-green-400,
|
||||
.from-green-500,
|
||||
.from-green-600,
|
||||
.from-green-700,
|
||||
.from-green-800,
|
||||
.from-green-900,
|
||||
.to-green-50,
|
||||
.to-green-100,
|
||||
.to-green-200,
|
||||
.to-green-300,
|
||||
.to-green-400,
|
||||
.to-green-500,
|
||||
.to-green-600,
|
||||
.to-green-700,
|
||||
.to-green-800,
|
||||
.to-green-900 {
|
||||
.hover\:bg-green-900:hover {
|
||||
background-color: transparent !important;
|
||||
color: inherit !important;
|
||||
border-color: transparent !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* Force remove green from any element */
|
||||
|
|
@ -322,7 +289,6 @@ body {
|
|||
background-color: transparent !important;
|
||||
color: inherit !important;
|
||||
border-color: transparent !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* Additional Variables */
|
||||
|
|
@ -365,40 +331,18 @@ body {
|
|||
background-color: var(--color-gray-50);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h1 { font-size: 2.5rem; }
|
||||
h2 { font-size: 2rem; }
|
||||
h3 { font-size: 1.5rem; }
|
||||
h4 { font-size: 1.25rem; }
|
||||
h5 { font-size: 1.125rem; }
|
||||
h6 { font-size: 1rem; }
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
|
|
@ -414,61 +358,6 @@ body {
|
|||
a:hover {
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tailwind CSS Components Layer */
|
||||
|
|
@ -540,138 +429,20 @@ body {
|
|||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.75rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
|
||||
/* tailwindcss@3.4.1 - Mobile Responsive Overrides */
|
||||
.container {
|
||||
@apply px-3;
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
@apply px-5 py-2.5 text-sm;
|
||||
}
|
||||
|
||||
|
||||
.card {
|
||||
@apply p-6;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
|
||||
/* Blue Highlighter / Stabilo Effect - Per-letter animated */
|
||||
.highlight-marker {
|
||||
position: relative;
|
||||
display: inline;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
color: transparent;
|
||||
background: linear-gradient(to right, #ffffff 50%, #374151 50%);
|
||||
background-size: 200% 100%;
|
||||
background-position: 100% 0;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: marker-text-reveal 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: calc(var(--delay, 0) * 1s);
|
||||
}
|
||||
|
||||
.highlight-marker::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
background: rgba(25, 25, 112, 0.85);
|
||||
border-radius: 3px;
|
||||
z-index: -1;
|
||||
animation: marker-sweep 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
animation-delay: calc(var(--delay, 0) * 1s);
|
||||
}
|
||||
|
||||
@keyframes marker-sweep {
|
||||
0% {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marker-text-reveal {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark .highlight-marker {
|
||||
background: linear-gradient(to right, #ffffff 50%, #d1d5db 50%);
|
||||
background-size: 200% 100%;
|
||||
background-position: 100% 0;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.dark .highlight-marker::before {
|
||||
background: rgba(80, 100, 220, 0.85);
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #191970;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #101050;
|
||||
}
|
||||
|
||||
/* Firefox Scrollbar */
|
||||
@supports (scrollbar-color: auto) {
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #191970 #f1f1f1;
|
||||
}
|
||||
|
||||
.dark html {
|
||||
scrollbar-color: #191970 #1f2937;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FiUsers, FiCalendar, FiFileText, FiCheckCircle } from 'react-icons/fi';
|
||||
|
||||
const KonselorDashboard = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<h1 className="text-xl font-bold text-gray-900">Dashboard Konselor</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">Konselor</span>
|
||||
<div className="w-8 h-8 bg-accent rounded-full flex items-center justify-center">
|
||||
<FiUsers className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Welcome Section */}
|
||||
<motion.div
|
||||
className="bg-gradient-to-r from-accent to-primary rounded-2xl p-8 mb-8 text-white"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-2">Dashboard Konseling</h2>
|
||||
<p className="opacity-90">
|
||||
Kelola dan monitoring layanan konseling untuk sivitas akademika
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiFileText className="w-8 h-8 text-primary" />
|
||||
<span className="text-2xl font-bold text-gray-900">45</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Total Laporan</h3>
|
||||
<p className="text-sm text-gray-600">Semua laporan masuk</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiCalendar className="w-8 h-8 text-accent" />
|
||||
<span className="text-2xl font-bold text-gray-900">8</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Jadwal Hari Ini</h3>
|
||||
<p className="text-sm text-gray-600">Sesi konseling</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiCheckCircle className="w-8 h-8 text-green-500" />
|
||||
<span className="text-2xl font-bold text-gray-900">32</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Selesai</h3>
|
||||
<p className="text-sm text-gray-600">Laporan selesai</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiUsers className="w-8 h-8 text-purple-500" />
|
||||
<span className="text-2xl font-bold text-gray-900">13</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Sedang Diproses</h3>
|
||||
<p className="text-sm text-gray-600">Laporan aktif</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Recent Reports */}
|
||||
<motion.div
|
||||
className="bg-white rounded-xl shadow-sm p-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Laporan Membutuhkan Perhatian</h3>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div key={item} className="flex items-center justify-between p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">Kasus #{item}</h4>
|
||||
<p className="text-sm text-gray-600">Prioritas Tinggi • 1 jam lalu</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors">
|
||||
Lihat Detail
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KonselorDashboard;
|
||||
|
|
@ -6,28 +6,32 @@ import Services from '../components/Services';
|
|||
import Articles from '../components/Articles';
|
||||
import Contact from '../components/Contact';
|
||||
import Footer from '../components/Footer';
|
||||
import LoginModal from '../components/LoginModal';
|
||||
import { heroService } from '../services/heroService';
|
||||
|
||||
const LandingPage = () => {
|
||||
const [heroData, setHeroData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Temporary disable API calls to prevent 404 errors
|
||||
const mockHeroData = {
|
||||
title: 'Aman Bicara, Aman Melapor',
|
||||
subtitle: 'Satgas PPKPT Politeknik Negeri Jember',
|
||||
description: null
|
||||
};
|
||||
|
||||
setHeroData(mockHeroData);
|
||||
setLoading(false);
|
||||
fetchHeroData();
|
||||
}, []);
|
||||
|
||||
const fetchHeroData = async () => {
|
||||
try {
|
||||
const response = await heroService.get();
|
||||
if (response.success) {
|
||||
setHeroData(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching hero data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar />
|
||||
<LoginModal />
|
||||
<Hero heroData={heroData} />
|
||||
<About />
|
||||
<Services />
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FiSettings, FiUsers, FiFileText, FiEdit, FiDatabase } from 'react-icons/fi';
|
||||
|
||||
const OperatorDashboard = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<h1 className="text-xl font-bold text-gray-900">Dashboard Operator</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">Operator</span>
|
||||
<div className="w-8 h-8 bg-red-500 rounded-full flex items-center justify-center">
|
||||
<FiSettings className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Welcome Section */}
|
||||
<motion.div
|
||||
className="bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl p-8 mb-8 text-white"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-2">Sistem Administrasi</h2>
|
||||
<p className="opacity-90">
|
||||
Kelola seluruh sistem PolijeCare dan monitoring aktivitas
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Admin Functions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiUsers className="w-8 h-8 text-blue-500" />
|
||||
<span className="text-2xl font-bold text-gray-900">156</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Manajemen Pengguna</h3>
|
||||
<p className="text-sm text-gray-600">Kelola akun pengguna</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiFileText className="w-8 h-8 text-green-500" />
|
||||
<span className="text-2xl font-bold text-gray-900">89</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Manajemen Pengaduan</h3>
|
||||
<p className="text-sm text-gray-600">Kelola semua pengaduan</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiEdit className="w-8 h-8 text-purple-500" />
|
||||
<span className="text-2xl font-bold text-gray-900">24</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Manajemen Artikel</h3>
|
||||
<p className="text-sm text-gray-600">Kelola konten artikel</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiDatabase className="w-8 h-8 text-orange-500" />
|
||||
<span className="text-2xl font-bold text-gray-900">12</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Manajemen Kategori</h3>
|
||||
<p className="text-sm text-gray-600">Kelola kategori laporan</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiSettings className="w-8 h-8 text-red-500" />
|
||||
<span className="text-sm font-medium text-gray-900">Pengaturan</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Setting Admin</h3>
|
||||
<p className="text-sm text-gray-600">Konfigurasi sistem</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiFileText className="w-8 h-8 text-indigo-500" />
|
||||
<span className="text-2xl font-bold text-gray-900">5</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Audit Trail</h3>
|
||||
<p className="text-sm text-gray-600">Log aktivitas sistem</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* System Status */}
|
||||
<motion.div
|
||||
className="bg-white rounded-xl shadow-sm p-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Status Sistem</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h4 className="font-medium text-green-800 mb-2">Database</h4>
|
||||
<p className="text-sm text-green-600">Normal • 99.9% Uptime</p>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="font-medium text-blue-800 mb-2">API Server</h4>
|
||||
<p className="text-sm text-blue-600">Normal • Response: 45ms</p>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<h4 className="font-medium text-yellow-800 mb-2">Storage</h4>
|
||||
<p className="text-sm text-yellow-600">Warning • 85% Used</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperatorDashboard;
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FiFileText, FiClock, FiUser, FiPlus } from 'react-icons/fi';
|
||||
|
||||
const UserDashboard = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<h1 className="text-xl font-bold text-gray-900">Dashboard Mahasiswa</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">Mahasiswa</span>
|
||||
<div className="w-8 h-8 bg-primary rounded-full flex items-center justify-center">
|
||||
<FiUser className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Welcome Section */}
|
||||
<motion.div
|
||||
className="bg-gradient-to-r from-primary to-accent rounded-2xl p-8 mb-8 text-white"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-2">Selamat Datang di PolijeCare</h2>
|
||||
<p className="opacity-90">
|
||||
Portal layanan perlindungan dan kesejahteraan sivitas akademika Politeknik Negeri Jember
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiPlus className="w-8 h-8 text-primary" />
|
||||
<span className="text-2xl font-bold text-gray-900">+</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Buat Laporan</h3>
|
||||
<p className="text-sm text-gray-600">Ajukan pengaduan atau konseling</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiFileText className="w-8 h-8 text-accent" />
|
||||
<span className="text-2xl font-bold text-gray-900">12</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Riwayat Laporan</h3>
|
||||
<p className="text-sm text-gray-600">Lihat semua laporan Anda</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiClock className="w-8 h-8 text-green-500" />
|
||||
<span className="text-2xl font-bold text-gray-900">3</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Sedang Diproses</h3>
|
||||
<p className="text-sm text-gray-600">Laporan dalam proses</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<FiUser className="w-8 h-8 text-purple-500" />
|
||||
<span className="text-sm font-medium text-primary">Profil</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Pengaturan</h3>
|
||||
<p className="text-sm text-gray-600">Kelola akun Anda</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Recent Reports */}
|
||||
<motion.div
|
||||
className="bg-white rounded-xl shadow-sm p-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Laporan Terbaru</h3>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div key={item} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">Laporan #{item}</h4>
|
||||
<p className="text-sm text-gray-600">Dibuat 2 hari yang lalu</p>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-yellow-100 text-yellow-800 text-sm rounded-full">
|
||||
Sedang Diproses
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDashboard;
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import axios from 'axios';
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:8000', // Backend URL
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// Handle 401 Unauthorized - only redirect if not already on home page
|
||||
if (error.response?.status === 401 && window.location.pathname !== '/') {
|
||||
localStorage.removeItem('token');
|
||||
delete api.defaults.headers.common['Authorization'];
|
||||
// Use navigate instead of direct location change to avoid infinite loop
|
||||
console.log('🔐 Token expired, redirecting to home...');
|
||||
}
|
||||
|
||||
// Handle 403 Forbidden
|
||||
if (error.response?.status === 403) {
|
||||
console.error('Access forbidden - insufficient permissions');
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
|
@ -4,119 +4,77 @@ export default {
|
|||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: ['class', "class"],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
dark: '#D8BFD8',
|
||||
light: '#F0E6FF',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
light: '#5C7CFA',
|
||||
dark: '#364FC7',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
light: '#6C757D',
|
||||
dark: '#343A40',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: '#DC2626',
|
||||
light: '#EF4444',
|
||||
dark: '#B91C1C'
|
||||
},
|
||||
gray: {
|
||||
'50': '#F5F7FA',
|
||||
'100': '#E5E7EB',
|
||||
'200': '#D1D5DB',
|
||||
'300': '#9CA3AF',
|
||||
'400': '#6B7280',
|
||||
'500': '#4B5563',
|
||||
'600': '#374151',
|
||||
'700': '#1F2937',
|
||||
'800': '#111827',
|
||||
'900': '#030712'
|
||||
},
|
||||
purple: {
|
||||
'50': '#F3E8FF',
|
||||
'100': '#E9D5FF',
|
||||
'200': '#D8B4FE'
|
||||
},
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Inter',
|
||||
'system-ui',
|
||||
'sans-serif'
|
||||
]
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'80': '20rem',
|
||||
'88': '22rem'
|
||||
},
|
||||
borderRadius: {
|
||||
xl: '0.75rem',
|
||||
'2xl': '1rem',
|
||||
'3xl': '1.5rem',
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
boxShadow: {
|
||||
soft: '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||
card: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
'card-hover': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)'
|
||||
},
|
||||
animation: {
|
||||
float: 'float 3s ease-in-out infinite',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite'
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
'0%, 100%': {
|
||||
transform: 'translateY(0px)'
|
||||
},
|
||||
'50%': {
|
||||
transform: 'translateY(-10px)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#E6E6FA',
|
||||
dark: '#D8BFD8',
|
||||
light: '#F0E6FF',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: '#4C6EF5',
|
||||
light: '#5C7CFA',
|
||||
dark: '#364FC7',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: '#495057',
|
||||
light: '#6C757D',
|
||||
dark: '#343A40',
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: '#DC2626',
|
||||
light: '#EF4444',
|
||||
dark: '#B91C1C',
|
||||
},
|
||||
gray: {
|
||||
50: '#F5F7FA',
|
||||
100: '#E5E7EB',
|
||||
200: '#D1D5DB',
|
||||
300: '#9CA3AF',
|
||||
400: '#6B7280',
|
||||
500: '#4B5563',
|
||||
600: '#374151',
|
||||
700: '#1F2937',
|
||||
800: '#111827',
|
||||
900: '#030712',
|
||||
},
|
||||
purple: {
|
||||
50: '#F3E8FF',
|
||||
100: '#E9D5FF',
|
||||
200: '#D8B4FE',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'80': '20rem',
|
||||
'88': '22rem',
|
||||
},
|
||||
borderRadius: {
|
||||
'xl': '0.75rem',
|
||||
'2xl': '1rem',
|
||||
'3xl': '1.5rem',
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||
'card': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
'card-hover': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
animation: {
|
||||
'float': 'float 3s ease-in-out infinite',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0px)' },
|
||||
'50%': { transform: 'translateY(-10px)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
plugins: [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||