DONE: digital card with profile display: WIP: membership condition on letter, is_first_time column in users table
This commit is contained in:
+4
-1
@@ -95,4 +95,7 @@ API_ALLOWED_USER_AGENTS=
|
||||
|
||||
ENABLE_API_KEY_AUTH=true
|
||||
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
|
||||
API_LOG_KEY_USAGE=true
|
||||
API_LOG_KEY_USAGE=true
|
||||
|
||||
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
|
||||
FRONTEND_URL=https://mykopkb.koppkb.com
|
||||
+4
-1
@@ -102,4 +102,7 @@ API_ALLOWED_USER_AGENTS=
|
||||
|
||||
ENABLE_API_KEY_AUTH=true
|
||||
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
|
||||
API_LOG_KEY_USAGE=true
|
||||
API_LOG_KEY_USAGE=true
|
||||
|
||||
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
|
||||
FRONTEND_URL=https://mykopkb.koppkb.com
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
@@ -51,6 +52,7 @@ class User extends Authenticatable
|
||||
'marriage_status',
|
||||
'member_number',
|
||||
'member_type',
|
||||
'public_profile_token',
|
||||
'join_date',
|
||||
'birth_date',
|
||||
'birth_place',
|
||||
@@ -67,8 +69,55 @@ class User extends Authenticatable
|
||||
'two_factor_secret',
|
||||
'two_factor_recovery_codes',
|
||||
'two_factor_confirmed_at',
|
||||
'public_profile_token',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (User $user) {
|
||||
$expiryDays = self::publicProfileTokenTtlDays();
|
||||
|
||||
if (empty($user->public_profile_token)) {
|
||||
$user->public_profile_token = Str::random(48);
|
||||
}
|
||||
|
||||
if (empty($user->public_profile_token_expires_at)) {
|
||||
$user->public_profile_token_expires_at = now()->addDays($expiryDays);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function publicProfileTokenTtlDays(): int
|
||||
{
|
||||
return (int) config('user.public_profile_token_ttl_days', 90);
|
||||
}
|
||||
|
||||
public function isPublicProfileTokenExpired(): bool
|
||||
{
|
||||
return $this->public_profile_token_expires_at !== null
|
||||
&& $this->public_profile_token_expires_at->isPast();
|
||||
}
|
||||
|
||||
public function ensurePublicProfileToken(): string
|
||||
{
|
||||
$expiryDays = self::publicProfileTokenTtlDays();
|
||||
|
||||
if (blank($this->public_profile_token) || $this->isPublicProfileTokenExpired()) {
|
||||
$this->forceFill([
|
||||
'public_profile_token' => Str::random(48),
|
||||
'public_profile_token_expires_at' => now()->addDays($expiryDays),
|
||||
])->save();
|
||||
|
||||
return $this->public_profile_token;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'public_profile_token_expires_at' => now()->addDays($expiryDays),
|
||||
])->save();
|
||||
|
||||
return $this->public_profile_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
@@ -84,6 +133,7 @@ class User extends Authenticatable
|
||||
'marriage_status' => 'string',
|
||||
'member_number' => 'integer',
|
||||
'member_type' => 'string',
|
||||
'public_profile_token_expires_at' => 'datetime',
|
||||
'join_date' => 'date',
|
||||
'birth_date' => 'date',
|
||||
'birth_place' => 'string',
|
||||
|
||||
@@ -24,6 +24,7 @@ class SessionController extends Controller
|
||||
], 401);
|
||||
}
|
||||
|
||||
$user->ensurePublicProfileToken();
|
||||
$user->load(['roles.permissions']);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -12,6 +12,8 @@ class AuthSessionService
|
||||
{
|
||||
public function createAuthResponse(User $user, string $message, int $status = 200): JsonResponse
|
||||
{
|
||||
$user->ensurePublicProfileToken();
|
||||
|
||||
if (config('app.env') !== 'local') {
|
||||
$user->tokens()->delete();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class UserResource extends JsonResource
|
||||
'marriage_status' => $this->marriage_status,
|
||||
'member_number' => $this->member_number,
|
||||
'member_type' => $this->member_type,
|
||||
'public_profile_token' => $this->public_profile_token,
|
||||
'join_date' => $this->join_date,
|
||||
'birth_date' => $this->birth_date,
|
||||
'birth_place' => $this->birth_place,
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
|
||||
return [
|
||||
'name' => 'User',
|
||||
'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90),
|
||||
'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'),
|
||||
'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo-kopkb.svg')),
|
||||
];
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('public_profile_token', 64)->nullable()->unique()->after('member_type');
|
||||
});
|
||||
|
||||
User::withTrashed()
|
||||
->whereNull('public_profile_token')
|
||||
->cursor()
|
||||
->each(function (User $user) {
|
||||
$user->forceFill([
|
||||
'public_profile_token' => Str::random(48),
|
||||
])->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropUnique(['public_profile_token']);
|
||||
$table->dropColumn('public_profile_token');
|
||||
});
|
||||
}
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->timestamp('public_profile_token_expires_at')->nullable()->after('public_profile_token');
|
||||
});
|
||||
|
||||
$expiryDays = (int) config('user.public_profile_token_ttl_days', 90);
|
||||
|
||||
User::withTrashed()
|
||||
->whereNotNull('public_profile_token')
|
||||
->whereNull('public_profile_token_expires_at')
|
||||
->cursor()
|
||||
->each(function (User $user) use ($expiryDays) {
|
||||
$user->forceFill([
|
||||
'public_profile_token_expires_at' => now()->addDays($expiryDays),
|
||||
])->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('public_profile_token_expires_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\User\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\User\Services\MemberDigitalCardService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class MemberDigitalCardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected MemberDigitalCardService $memberDigitalCardService
|
||||
) {}
|
||||
|
||||
public function download(Request $request): Response|JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'side' => 'sometimes|in:depan,belakang',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$side = $validated['side'] ?? 'depan';
|
||||
|
||||
if ($user->status !== 'active') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Kad digital hanya tersedia untuk anggota aktif.',
|
||||
'code' => 'digital_card_inactive',
|
||||
'data' => null,
|
||||
], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$png = $this->memberDigitalCardService->renderPng($user, $side);
|
||||
$memberNumber = $user->member_number ?: 'anggota';
|
||||
$filename = sprintf('kad-digital-%s-%s.png', $memberNumber, $side);
|
||||
|
||||
return response($png, Response::HTTP_OK, [
|
||||
'Content-Type' => 'image/png',
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||
'Cache-Control' => 'no-store, private',
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('Failed to render member digital card.', [
|
||||
'user_id' => $user->id,
|
||||
'side' => $side,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Gagal menjana kad digital. Sila cuba lagi.',
|
||||
'code' => 'digital_card_render_failed',
|
||||
'data' => null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\User\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\User\Services\PublicMemberProfileService;
|
||||
use Modules\User\Transformers\PublicMemberProfileResource;
|
||||
|
||||
class PublicMemberProfileController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PublicMemberProfileService $publicMemberProfileService
|
||||
) {}
|
||||
|
||||
public function show(string $token): JsonResponse
|
||||
{
|
||||
$member = $this->publicMemberProfileService->findMemberByToken($token);
|
||||
|
||||
if (! $member) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Anggota tidak dijumpai atau tidak sah.',
|
||||
'code' => 'public_profile_not_found',
|
||||
'data' => null,
|
||||
], 404);
|
||||
}
|
||||
|
||||
$member = $this->publicMemberProfileService->loadVerifiableRelations($member);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Anggota disahkan.',
|
||||
'data' => new PublicMemberProfileResource($member),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ namespace Modules\User\Providers;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\User\Policies\UserPolicy;
|
||||
use Modules\User\Entities\Address;
|
||||
@@ -34,6 +36,7 @@ class UserServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerRateLimiters();
|
||||
$this->registerCommands();
|
||||
$this->registerCommandSchedules();
|
||||
$this->registerTranslations();
|
||||
@@ -96,6 +99,13 @@ class UserServiceProvider extends ServiceProvider
|
||||
Gate::policy(Heir::class, HeirPolicy::class);
|
||||
}
|
||||
|
||||
protected function registerRateLimiters(): void
|
||||
{
|
||||
RateLimiter::for('public-member-profile', function ($request) {
|
||||
return Limit::perMinute(60)->by($request->ip());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register commands in the format of Command::class
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ms">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 840px;
|
||||
height: 540px;
|
||||
overflow: hidden;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
.card {
|
||||
position: relative;
|
||||
width: 840px;
|
||||
height: 540px;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
color: #eff6ff;
|
||||
background: linear-gradient(to bottom right, #1e3a8a, #1e40af, #2563eb);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
outline: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 24px;
|
||||
}
|
||||
.orb-1 {
|
||||
position: absolute;
|
||||
right: -48px;
|
||||
top: -48px;
|
||||
width: 176px;
|
||||
height: 176px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.orb-2 {
|
||||
position: absolute;
|
||||
bottom: -64px;
|
||||
left: -40px;
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
.badge {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
opacity: 0.75;
|
||||
text-align: right;
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.qr-wrap {
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.qr-wrap img {
|
||||
display: block;
|
||||
width: 144px;
|
||||
height: 144px;
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.info p {
|
||||
font-size: 18px;
|
||||
line-height: 1.375;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.member-number {
|
||||
margin-top: 2px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
text-align: center;
|
||||
}
|
||||
.footer p {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="orb-1"></div>
|
||||
<div class="orb-2"></div>
|
||||
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
@if ($logoDataUri)
|
||||
<img src="{{ $logoDataUri }}" alt="" class="logo">
|
||||
@endif
|
||||
<div class="badge">Belakang · Kod QR</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="qr-wrap">
|
||||
<img src="{{ $qrDataUri }}" alt="Kod QR profil anggota">
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<p>Imbas untuk sahkan profil anggota MyKOPKB.</p>
|
||||
|
||||
<div>
|
||||
<div class="label">No. Anggota</div>
|
||||
<div class="member-number">{{ $memberNumber }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Koperasi Permodalan Kelantan Berhad (KOPKB)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,214 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ms">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 840px;
|
||||
height: 540px;
|
||||
overflow: hidden;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
.card {
|
||||
position: relative;
|
||||
width: 840px;
|
||||
height: 540px;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
color: #eff6ff;
|
||||
background: linear-gradient(to bottom right, #1e3a8a, #1e40af, #1d4ed8);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
outline: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 32px;
|
||||
}
|
||||
.orb-1 {
|
||||
position: absolute;
|
||||
right: -40px;
|
||||
top: -40px;
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.orb-2 {
|
||||
position: absolute;
|
||||
bottom: -48px;
|
||||
left: -32px;
|
||||
width: 208px;
|
||||
height: 208px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.avatar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 24px;
|
||||
transform: translateY(-50%);
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.avatar-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
.badge {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
opacity: 0.8;
|
||||
text-align: right;
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.member-number {
|
||||
margin-top: 2px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 48px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
.company-block {
|
||||
min-width: 0;
|
||||
padding-right: 152px;
|
||||
}
|
||||
.company-value {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.footer-name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.footer-type {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="orb-1"></div>
|
||||
<div class="orb-2"></div>
|
||||
|
||||
<div class="avatar">
|
||||
@if ($avatarDataUri)
|
||||
<img src="{{ $avatarDataUri }}" alt="{{ $memberName }}">
|
||||
@else
|
||||
<div class="avatar-fallback">{{ $avatarInitials }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
@if ($logoDataUri)
|
||||
<img src="{{ $logoDataUri }}" alt="" class="logo">
|
||||
@endif
|
||||
<div class="badge">Kad Digital</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div>
|
||||
<div class="label">No. Anggota</div>
|
||||
<div class="member-number">{{ $memberNumber }}</div>
|
||||
</div>
|
||||
|
||||
<div class="company-block">
|
||||
<div class="label">Unit</div>
|
||||
<div class="company-value">{{ $companyName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>
|
||||
<div class="footer-name">{{ $memberName }}</div>
|
||||
<div class="footer-sub">Nama</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="footer-type">{{ $memberType }}</div>
|
||||
<div class="footer-sub">Jenis Anggota</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,6 +8,14 @@ use Modules\User\Http\Controllers\EmploymentController;
|
||||
use Modules\User\Http\Controllers\BankController;
|
||||
use Modules\User\Http\Controllers\BankDetailController;
|
||||
use Modules\User\Http\Controllers\HeirController;
|
||||
use Modules\User\Http\Controllers\MemberDigitalCardController;
|
||||
use Modules\User\Http\Controllers\PublicMemberProfileController;
|
||||
|
||||
Route::prefix('v1/public')->group(function () {
|
||||
Route::middleware('throttle:public-member-profile')
|
||||
->get('members/{token}', [PublicMemberProfileController::class, 'show'])
|
||||
->name('user.public.member-profile');
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
|
||||
Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index');
|
||||
@@ -28,6 +36,8 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
|
||||
// User profile management routes
|
||||
Route::post('/profile', [UserController::class, 'updateProfile']);
|
||||
Route::put('/profile/password', [UserController::class, 'updatePassword']);
|
||||
Route::get('/profile/digital-card', [MemberDigitalCardController::class, 'download'])
|
||||
->name('profile.digital-card.download');
|
||||
|
||||
// Impersonation routes
|
||||
Route::get('/impersonate/take/{id}', [ImpersonateController::class, 'take'])->name('impersonate');
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\User\Services;
|
||||
|
||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||
use BaconQrCode\Renderer\ImageRenderer;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
use BaconQrCode\Writer;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
|
||||
class MemberDigitalCardService
|
||||
{
|
||||
public const CARD_WIDTH = 840;
|
||||
|
||||
public const CARD_HEIGHT = 540;
|
||||
|
||||
public function renderPng(User $user, string $side): string
|
||||
{
|
||||
$view = $side === 'belakang'
|
||||
? 'user::digital-card.back'
|
||||
: 'user::digital-card.front';
|
||||
|
||||
$html = View::make($view, $this->prepareViewData($user))->render();
|
||||
|
||||
return $this->makeBrowsershot($html)->screenshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function prepareViewData(User $user): array
|
||||
{
|
||||
$user = app(PublicMemberProfileService::class)->loadVerifiableRelations($user);
|
||||
$user->ensurePublicProfileToken();
|
||||
|
||||
$currentEmployment = $user->employments->firstWhere('is_current', true)
|
||||
?? $user->employments->first();
|
||||
|
||||
$logoPath = config('user.digital_card_logo_path');
|
||||
$profileUrl = rtrim(config('user.frontend_url'), '/').'/v/'.$user->public_profile_token;
|
||||
|
||||
return [
|
||||
'memberNumber' => $this->displayCardValue($user->member_number),
|
||||
'memberName' => $user->name ?: '-',
|
||||
'memberType' => $this->displayCardValue($user->member_type),
|
||||
'companyName' => $this->displayCardValue($currentEmployment?->company_name),
|
||||
'avatarDataUri' => $this->resolveStorageDataUri($user->image_url),
|
||||
'avatarInitials' => $this->avatarInitials($user->name),
|
||||
'logoDataUri' => $this->resolveFileDataUri($logoPath),
|
||||
'profileUrl' => $profileUrl,
|
||||
'qrDataUri' => $this->generateQrDataUri($profileUrl),
|
||||
];
|
||||
}
|
||||
|
||||
protected function makeBrowsershot(string $html): Browsershot
|
||||
{
|
||||
$browsershot = Browsershot::html($html)
|
||||
->setNodeModulePath(config('browsershot.node_module_path'))
|
||||
->timeout(config('browsershot.timeout'))
|
||||
->windowSize(self::CARD_WIDTH, self::CARD_HEIGHT)
|
||||
->deviceScaleFactor(2)
|
||||
->showBackground();
|
||||
|
||||
if ($nodeBinary = config('browsershot.node_binary')) {
|
||||
$browsershot->setNodeBinary($nodeBinary);
|
||||
}
|
||||
|
||||
if ($npmBinary = config('browsershot.npm_binary')) {
|
||||
$browsershot->setNpmBinary($npmBinary);
|
||||
}
|
||||
|
||||
if ($chromePath = config('browsershot.chrome_path')) {
|
||||
$browsershot->setChromePath($chromePath);
|
||||
}
|
||||
|
||||
if (config('browsershot.no_sandbox')) {
|
||||
$browsershot->noSandbox();
|
||||
}
|
||||
|
||||
return $browsershot;
|
||||
}
|
||||
|
||||
protected function generateQrDataUri(string $content): string
|
||||
{
|
||||
$renderer = new ImageRenderer(
|
||||
new RendererStyle(220, 1),
|
||||
new SvgImageBackEnd
|
||||
);
|
||||
$writer = new Writer($renderer);
|
||||
$svg = $writer->writeString($content);
|
||||
|
||||
return 'data:image/svg+xml;base64,'.base64_encode($svg);
|
||||
}
|
||||
|
||||
protected function resolveFileDataUri(?string $path): ?string
|
||||
{
|
||||
if (empty($path) || ! file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mime = mime_content_type($path) ?: 'image/svg+xml';
|
||||
|
||||
return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($path));
|
||||
}
|
||||
|
||||
protected function resolveStorageDataUri(?string $path): ?string
|
||||
{
|
||||
if (blank($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fullPath = Storage::disk('public')->path($path);
|
||||
|
||||
return $this->resolveFileDataUri($fullPath);
|
||||
}
|
||||
|
||||
protected function avatarInitials(?string $name): string
|
||||
{
|
||||
$trimmed = trim((string) $name);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return strtoupper(mb_substr($trimmed, 0, 2));
|
||||
}
|
||||
|
||||
protected function displayCardValue(mixed $value): string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return '-';
|
||||
}
|
||||
|
||||
$trimmed = trim((string) $value);
|
||||
|
||||
return $trimmed !== '' ? $trimmed : '-';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\User\Services;
|
||||
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class PublicMemberProfileService
|
||||
{
|
||||
public function findMemberByToken(string $token): ?User
|
||||
{
|
||||
return User::query()
|
||||
->where('public_profile_token', $token)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function loadVerifiableRelations(User $member): User
|
||||
{
|
||||
$member->load(['employments' => function ($query) {
|
||||
$query->orderByDesc('is_current')->orderByDesc('start_date');
|
||||
}]);
|
||||
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\User\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PublicMemberProfileResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$currentEmployment = $this->employments->firstWhere('is_current', true)
|
||||
?? $this->employments->first();
|
||||
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'member_number' => $this->member_number,
|
||||
'member_type' => $this->member_type,
|
||||
'status' => $this->status,
|
||||
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
|
||||
'company_name' => $currentEmployment?->company_name,
|
||||
'verified_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"sweetalert2": "^11.26.25",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
@@ -71,6 +72,7 @@
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/node": "^24.10.4",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitest/eslint-plugin": "^1.6.4",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
|
||||
Generated
+164
@@ -119,6 +119,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
sweetalert2:
|
||||
specifier: ^11.26.25
|
||||
version: 11.26.25
|
||||
@@ -156,6 +159,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^24.10.4
|
||||
version: 24.12.4
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.7(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.9.3))
|
||||
@@ -1041,6 +1047,9 @@ packages:
|
||||
'@types/pako@2.0.4':
|
||||
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||
|
||||
@@ -1588,6 +1597,10 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001793:
|
||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||
|
||||
@@ -1637,6 +1650,9 @@ packages:
|
||||
resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1758,6 +1774,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
@@ -1784,6 +1804,9 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dompurify@3.4.7:
|
||||
resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==}
|
||||
|
||||
@@ -2055,6 +2078,10 @@ packages:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2109,6 +2136,10 @@ packages:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-east-asian-width@1.6.0:
|
||||
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2501,6 +2532,10 @@ packages:
|
||||
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2679,14 +2714,26 @@ packages:
|
||||
ospath@1.2.2:
|
||||
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-locate@5.0.0:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
@@ -2769,6 +2816,10 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2833,6 +2884,11 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
qs@6.15.2:
|
||||
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -2856,10 +2912,17 @@ packages:
|
||||
request-progress@3.0.0:
|
||||
resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2916,6 +2979,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3393,6 +3459,9 @@ packages:
|
||||
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -3420,6 +3489,10 @@ packages:
|
||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3467,9 +3540,20 @@ packages:
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yauzl@3.3.2:
|
||||
resolution: {integrity: sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4215,6 +4299,10 @@ snapshots:
|
||||
|
||||
'@types/pako@2.0.4': {}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 24.12.4
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
optional: true
|
||||
|
||||
@@ -4978,6 +5066,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001793: {}
|
||||
|
||||
canvg@3.0.11:
|
||||
@@ -5033,6 +5123,12 @@ snapshots:
|
||||
slice-ansi: 8.0.0
|
||||
string-width: 8.2.1
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
codepage@1.15.0: {}
|
||||
@@ -5172,6 +5268,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
supports-color: 8.1.1
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
@@ -5189,6 +5287,8 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dompurify@3.4.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
@@ -5505,6 +5605,11 @@ snapshots:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
find-up@5.0.0:
|
||||
dependencies:
|
||||
locate-path: 6.0.0
|
||||
@@ -5554,6 +5659,8 @@ snapshots:
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.6.0: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
@@ -5928,6 +6035,10 @@ snapshots:
|
||||
rfdc: 1.4.1
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -6107,14 +6218,24 @@ snapshots:
|
||||
|
||||
ospath@1.2.2: {}
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-locate@5.0.0:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
pako@2.1.0: {}
|
||||
@@ -6177,6 +6298,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
@@ -6225,6 +6348,12 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
qs@6.15.2:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
@@ -6250,8 +6379,12 @@ snapshots:
|
||||
dependencies:
|
||||
throttleit: 1.0.1
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-protobuf-schema@2.1.0:
|
||||
@@ -6323,6 +6456,8 @@ snapshots:
|
||||
|
||||
semver@7.8.1: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
@@ -6781,6 +6916,8 @@ snapshots:
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -6800,6 +6937,12 @@ snapshots:
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -6843,8 +6986,29 @@ snapshots:
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yauzl@3.3.2:
|
||||
dependencies:
|
||||
pend: 1.2.0
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface AuthUser {
|
||||
image_url: string | null
|
||||
member_number: number | null
|
||||
member_type: string | null
|
||||
public_profile_token: string | null
|
||||
status: string
|
||||
gender: string | null
|
||||
marriage_status: string | null
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { downloadMemberDigitalCard } from '../services/member-digital-card.service'
|
||||
import { toProxiedStorageUrl } from '../utils/member-digital-card.utils'
|
||||
import MemberDigitalCardFlip from './MemberDigitalCardFlip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
memberNumber?: string | number | null
|
||||
memberName?: string | null
|
||||
memberType?: string | null
|
||||
companyName?: string | null
|
||||
profileUrl?: string | null
|
||||
imageUrl?: string | null
|
||||
large?: boolean
|
||||
}>()
|
||||
|
||||
const previewMaxWidthClass = computed(() =>
|
||||
props.large ? 'max-w-sm sm:max-w-md lg:max-w-lg' : 'max-w-68 sm:max-w-xs',
|
||||
)
|
||||
|
||||
const resolvedImageUrl = computed(() => toProxiedStorageUrl(props.imageUrl))
|
||||
|
||||
const isFlipped = ref(false)
|
||||
const expandedOpen = ref(false)
|
||||
const isPortraitPhone = ref(false)
|
||||
const downloading = ref(false)
|
||||
|
||||
let portraitQuery: MediaQueryList | null = null
|
||||
|
||||
function updatePortraitPhone() {
|
||||
isPortraitPhone.value = portraitQuery?.matches ?? false
|
||||
}
|
||||
|
||||
function openExpanded() {
|
||||
expandedOpen.value = true
|
||||
}
|
||||
|
||||
function closeExpanded() {
|
||||
expandedOpen.value = false
|
||||
}
|
||||
|
||||
function toggleFlip() {
|
||||
isFlipped.value = !isFlipped.value
|
||||
}
|
||||
|
||||
async function downloadCard() {
|
||||
if (downloading.value) return
|
||||
|
||||
downloading.value = true
|
||||
const side = isFlipped.value ? 'belakang' : 'depan'
|
||||
|
||||
try {
|
||||
await downloadMemberDigitalCard(props.memberNumber, side)
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: `Kad ${side} berjaya disimpan.`,
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan kad.'),
|
||||
})
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(expandedOpen, (open) => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
portraitQuery = window.matchMedia('(max-width: 767px) and (orientation: portrait)')
|
||||
updatePortraitPhone()
|
||||
portraitQuery.addEventListener('change', updatePortraitPhone)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = ''
|
||||
portraitQuery?.removeEventListener('change', updatePortraitPhone)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full flex-col items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="relative w-full cursor-pointer border-0 bg-transparent p-0 transition-transform active:scale-[0.98]"
|
||||
:class="previewMaxWidthClass"
|
||||
aria-label="Buka kad digital penuh"
|
||||
@click="openExpanded">
|
||||
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
|
||||
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
|
||||
:image-url="resolvedImageUrl" :is-flipped="isFlipped" />
|
||||
</button>
|
||||
|
||||
<p class="text-center text-[11px] text-slate-500">
|
||||
Klik kad untuk paparan penuh
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-xs"
|
||||
:aria-pressed="isFlipped" :disabled="downloading" @click="toggleFlip">
|
||||
<Lucide class="mr-2 size-4" icon="RotateCw" />
|
||||
{{ isFlipped ? 'Papar depan kad' : 'Imbas kod QR' }}
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-xs"
|
||||
:disabled="downloading" @click="downloadCard">
|
||||
<Lucide class="mr-2 size-4" :icon="downloading ? 'LoaderCircle' : 'Download'"
|
||||
:class="{ 'animate-spin': downloading }" />
|
||||
{{ downloading ? 'Menyimpan...' : 'Simpan kad' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="expandedOpen"
|
||||
class="fixed inset-0 z-70 flex flex-col items-center justify-center gap-4 bg-black/90 p-5"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Kad digital anggota"
|
||||
@click.self="closeExpanded">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-4 top-4 flex size-10 items-center justify-center rounded-full border border-white/20 bg-white/10 text-white"
|
||||
aria-label="Tutup"
|
||||
@click="closeExpanded">
|
||||
<Lucide class="size-5" icon="X" />
|
||||
</button>
|
||||
|
||||
<div v-if="isPortraitPhone" class="flex items-center justify-center" @click.stop>
|
||||
<div class="w-[min(90vh,34rem)] rotate-90">
|
||||
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
|
||||
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
|
||||
:image-url="resolvedImageUrl" :is-flipped="isFlipped" expanded />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="w-[min(100vw-2rem,32rem)] lg:w-[min(100vw-2rem,40rem)]" @click.stop>
|
||||
<MemberDigitalCardFlip :member-number="memberNumber" :member-name="memberName"
|
||||
:member-type="memberType" :company-name="companyName" :profile-url="profileUrl"
|
||||
:image-url="resolvedImageUrl" :is-flipped="isFlipped" expanded />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-white/20 bg-white/10 text-xs text-white shadow-none hover:bg-white/15"
|
||||
:aria-pressed="isFlipped"
|
||||
:disabled="downloading"
|
||||
@click.stop="toggleFlip">
|
||||
<Lucide class="mr-2 size-4" icon="RotateCw" />
|
||||
{{ isFlipped ? 'Papar depan kad' : 'Imbas kod QR' }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-white/20 bg-white/10 text-xs text-white shadow-none hover:bg-white/15"
|
||||
:disabled="downloading"
|
||||
@click.stop="downloadCard">
|
||||
<Lucide class="mr-2 size-4" :icon="downloading ? 'LoaderCircle' : 'Download'"
|
||||
:class="{ 'animate-spin': downloading }" />
|
||||
{{ downloading ? 'Menyimpan...' : 'Simpan kad' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-white/60">
|
||||
Klik di luar kad untuk tutup
|
||||
</p>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import { displayCardValue } from '../utils/member-digital-card.utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
profileUrl?: string | null
|
||||
memberNumber?: string | number | null
|
||||
expanded?: boolean
|
||||
}>(),
|
||||
{ expanded: false },
|
||||
)
|
||||
|
||||
const qrDataUrl = ref('')
|
||||
const qrError = ref(false)
|
||||
|
||||
const qrPixelSize = computed(() => (props.expanded ? 220 : 120))
|
||||
|
||||
async function renderQrCode() {
|
||||
if (!props.profileUrl) {
|
||||
qrDataUrl.value = ''
|
||||
qrError.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
qrDataUrl.value = await QRCode.toDataURL(props.profileUrl, {
|
||||
margin: 1,
|
||||
width: qrPixelSize.value,
|
||||
color: {
|
||||
dark: '#0f172a',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
qrError.value = false
|
||||
} catch {
|
||||
qrDataUrl.value = ''
|
||||
qrError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.profileUrl, () => props.expanded], renderQrCode, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative h-full w-full overflow-hidden rounded-2xl bg-linear-to-br from-primary/90 via-primary to-primary/80 text-primary-foreground shadow-lg ring-1 ring-white/20',
|
||||
expanded ? 'p-5 sm:p-6' : 'p-3 sm:p-4',
|
||||
]">
|
||||
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
|
||||
<div class="pointer-events-none absolute -right-12 -top-12 rounded-full bg-white/10"
|
||||
:class="expanded ? 'size-44' : 'size-32'" />
|
||||
<div class="pointer-events-none absolute -bottom-16 -left-10 rounded-full bg-white/5"
|
||||
:class="expanded ? 'size-48' : 'size-36'" />
|
||||
|
||||
<div class="relative flex h-full min-h-0 flex-col">
|
||||
<div class="flex shrink-0 items-center justify-between gap-2">
|
||||
<img :src="logoUrl" alt="" class="w-auto shrink-0 brightness-0 invert"
|
||||
:class="expanded ? 'h-7 sm:h-9' : 'h-5 sm:h-6'" />
|
||||
<div class="text-right font-semibold uppercase opacity-75"
|
||||
:class="expanded ? 'text-sm tracking-[0.18em]' : 'text-[9px] tracking-[0.18em]'">
|
||||
Belakang · Kod QR
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 items-center" :class="expanded ? 'mt-5 gap-6' : 'mt-3 gap-3'">
|
||||
<div class="shrink-0 rounded-lg bg-white shadow-sm" :class="expanded ? 'p-3' : 'p-1.5'">
|
||||
<img v-if="qrDataUrl" :src="qrDataUrl" alt="Kod QR profil anggota" class="block"
|
||||
:class="expanded ? 'size-32 sm:size-36' : 'size-18 sm:size-20'" />
|
||||
<div v-else class="flex items-center justify-center"
|
||||
:class="expanded ? 'size-32 sm:size-36' : 'size-18 sm:size-20'">
|
||||
<span class="px-1 text-center leading-tight text-slate-500" :class="expanded ? 'text-sm' : 'text-[9px]'">
|
||||
{{ qrError ? 'Kod QR tidak tersedia.' : 'Memuatkan...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center" :class="expanded ? 'gap-5' : 'gap-3'">
|
||||
<p class="leading-snug opacity-85" :class="expanded ? 'text-base sm:text-lg' : 'text-[9px] sm:text-[10px]'">
|
||||
Imbas untuk sahkan profil anggota MyKOPKB.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<div class="font-medium uppercase tracking-widest opacity-60" :class="expanded ? 'text-sm' : 'text-[9px]'">
|
||||
No. Anggota
|
||||
</div>
|
||||
<div class="mt-0.5 font-mono font-semibold tracking-widest"
|
||||
:class="expanded ? 'text-3xl sm:text-4xl' : 'text-base sm:text-lg'">
|
||||
{{ displayCardValue(memberNumber) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 border-t border-white/15 text-center" :class="expanded ? 'mt-4 pt-3' : 'mt-2 pt-2'">
|
||||
<p class="uppercase opacity-50" :class="expanded ? 'text-xs tracking-[0.2em]' : 'text-[8px] tracking-[0.2em]'">
|
||||
Koperasi Permodalan Kelantan Berhad (KOPKB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup>
|
||||
import MemberDigitalCardBack from './MemberDigitalCardBack.vue'
|
||||
import MemberDigitalCardFront from './MemberDigitalCardFront.vue'
|
||||
|
||||
defineProps<{
|
||||
memberNumber?: string | number | null
|
||||
memberName?: string | null
|
||||
memberType?: string | null
|
||||
companyName?: string | null
|
||||
profileUrl?: string | null
|
||||
imageUrl?: string | null
|
||||
isFlipped: boolean
|
||||
expanded?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full" style="perspective: 1000px">
|
||||
<div
|
||||
class="relative aspect-7/4.5 w-full transition-transform duration-500 ease-in-out"
|
||||
:style="{
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||
}">
|
||||
<div class="absolute inset-0" style="backface-visibility: hidden">
|
||||
<MemberDigitalCardFront :member-number="memberNumber" :member-name="memberName"
|
||||
:member-type="memberType" :company-name="companyName" :image-url="imageUrl" :expanded="expanded" />
|
||||
</div>
|
||||
<div class="absolute inset-0" :style="{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }">
|
||||
<MemberDigitalCardBack :profile-url="profileUrl" :member-number="memberNumber" :expanded="expanded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import { displayCardValue } from '../utils/member-digital-card.utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
memberNumber?: string | number | null
|
||||
memberName?: string | null
|
||||
memberType?: string | null
|
||||
companyName?: string | null
|
||||
imageUrl?: string | null
|
||||
expanded?: boolean
|
||||
}>(),
|
||||
{ expanded: false },
|
||||
)
|
||||
|
||||
const avatarFallback = computed(() => {
|
||||
const name = props.memberName?.trim()
|
||||
if (!name) return '--'
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'relative h-full w-full overflow-hidden rounded-2xl bg-linear-to-br from-primary via-primary/95 to-primary/75 text-primary-foreground shadow-lg ring-1 ring-white/20',
|
||||
expanded ? 'p-6 sm:p-8' : 'p-4 sm:p-5',
|
||||
]">
|
||||
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
|
||||
<div class="pointer-events-none absolute -right-10 -top-10 rounded-full bg-white/10"
|
||||
:class="expanded ? 'size-48' : 'size-36'" />
|
||||
<div class="pointer-events-none absolute -bottom-12 -left-8 rounded-full bg-white/5"
|
||||
:class="expanded ? 'size-52' : 'size-40'" />
|
||||
<div
|
||||
class="pointer-events-none absolute top-1/2 -translate-y-1/2 overflow-hidden rounded-md border border-white/25 bg-white/10 shadow-sm"
|
||||
:class="expanded ? 'right-6 size-20 sm:size-24' : 'right-4 size-14'">
|
||||
<img v-if="imageUrl" :src="imageUrl" :alt="memberName ?? 'Profil anggota'" class="size-full object-cover" />
|
||||
<div v-else class="flex size-full items-center justify-center bg-white/15 font-semibold uppercase tracking-wide"
|
||||
:class="expanded ? 'text-base' : 'text-[11px]'">
|
||||
{{ avatarFallback }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex h-full min-h-0 flex-col">
|
||||
<div class="flex shrink-0 items-start justify-between gap-3">
|
||||
<img :src="logoUrl" alt="" class="w-auto brightness-0 invert"
|
||||
:class="expanded ? 'h-8 sm:h-10' : 'h-6 sm:h-7'" />
|
||||
<div class="text-right font-semibold uppercase opacity-80"
|
||||
:class="expanded ? 'text-sm tracking-[0.2em]' : 'text-[10px] tracking-[0.2em]'">
|
||||
Kad Digital
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col justify-center py-2" :class="expanded ? 'gap-4' : 'gap-2'">
|
||||
<div>
|
||||
<div class="font-medium uppercase tracking-widest opacity-70" :class="expanded ? 'text-sm' : 'text-[10px]'">
|
||||
No. Anggota
|
||||
</div>
|
||||
<div class="mt-0.5 font-mono font-semibold"
|
||||
:class="expanded ? 'text-4xl tracking-[0.15em] sm:text-5xl' : 'text-xl tracking-[0.15em] sm:text-2xl'">
|
||||
{{ displayCardValue(memberNumber) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0" :class="expanded ? 'pr-28 sm:pr-32' : 'pr-16'">
|
||||
<div class="font-medium uppercase tracking-widest opacity-70" :class="expanded ? 'text-sm' : 'text-[10px]'">
|
||||
Unit
|
||||
</div>
|
||||
<div class="truncate font-medium" :class="expanded ? 'text-lg sm:text-xl' : 'text-xs'">
|
||||
{{ displayCardValue(companyName) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-end justify-between gap-3 border-t border-white/15"
|
||||
:class="expanded ? 'pt-4' : 'pt-2'">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium" :class="expanded ? 'text-xl sm:text-2xl' : 'text-sm'">
|
||||
{{ memberName || '-' }}
|
||||
</div>
|
||||
<div class="mt-0.5 uppercase tracking-wide opacity-60" :class="expanded ? 'text-sm' : 'text-[10px]'">
|
||||
Nama
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="font-semibold" :class="expanded ? 'text-xl sm:text-2xl' : 'text-sm'">
|
||||
{{ displayCardValue(memberType) }}
|
||||
</div>
|
||||
<div class="mt-0.5 uppercase tracking-wide opacity-60" :class="expanded ? 'text-sm' : 'text-[10px]'">
|
||||
Jenis Anggota
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,2 +1,2 @@
|
||||
export { profileLayoutRoutes } from './routes'
|
||||
export { profileLayoutRoutes, profilePublicRoutes } from './routes'
|
||||
export { profileMenu } from './menu'
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getPublicMemberProfile } from '../services/public-member-profile.service'
|
||||
import type { PublicMemberProfile } from '../types/public-member-profile.types'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const member = ref<PublicMemberProfile | null>(null)
|
||||
|
||||
const token = computed(() => String(route.params.token ?? '').trim())
|
||||
|
||||
const avatarFallback = computed(() => {
|
||||
const name = member.value?.name?.trim()
|
||||
if (!name) return '--'
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
const displayValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
async function fetchMemberProfile() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
member.value = null
|
||||
|
||||
if (!token.value) {
|
||||
error.value = 'Pautan pengesahan tidak sah.'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getPublicMemberProfile(token.value)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message ?? 'Anggota tidak dijumpai atau tidak sah.')
|
||||
}
|
||||
|
||||
member.value = response.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan profil anggota.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMemberProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-100 px-4 py-10">
|
||||
<div class="mx-auto w-full max-w-md">
|
||||
<div class="mb-6 flex flex-col items-center text-center">
|
||||
<img :src="logoUrl" alt="MyKOPKB" class="h-10 w-auto" />
|
||||
<h1 class="mt-4 text-xl font-semibold text-slate-900">Maklumat Anggota</h1>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Pengesahan gagal</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box v-else-if="loading" raised="single" class="p-8 text-center text-sm text-slate-500">
|
||||
Memuatkan maklumat anggota...
|
||||
</Box>
|
||||
|
||||
<Box v-else-if="member" raised="single" class="overflow-hidden p-0">
|
||||
<div class="bg-linear-to-br from-primary via-primary/95 to-primary/75 p-6 text-primary-foreground">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<Badge class="bg-white/15 text-white">Disahkan</Badge>
|
||||
<div class="text-right text-[10px] font-semibold uppercase tracking-[0.2em] opacity-80">
|
||||
MyKOPKB
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<AvatarRoot class="size-16 border-4 border-white/20 bg-white/10">
|
||||
<AvatarFallback>{{ avatarFallback }}</AvatarFallback>
|
||||
<AvatarImage v-if="member.image_url" :src="member.image_url" :alt="member.name" />
|
||||
</AvatarRoot>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-lg font-semibold">{{ member.name }}</div>
|
||||
<div class="mt-1 text-sm opacity-80">{{ displayValue(member.member_type) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-6">
|
||||
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
|
||||
<Lucide class="size-5 text-primary" icon="IdCard" />
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">No. Anggota</div>
|
||||
<div class="font-mono text-base font-semibold text-slate-900">
|
||||
{{ displayValue(member.member_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
|
||||
<Lucide class="size-5 text-primary" icon="Briefcase" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Syarikat</div>
|
||||
<div class="truncate text-base font-medium text-slate-900">
|
||||
{{ displayValue(member.company_name) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
|
||||
<Lucide class="size-5 text-primary" icon="CircleCheck" />
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Status</div>
|
||||
<div class="text-base font-medium capitalize text-slate-900">
|
||||
{{ displayValue(member.status) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-slate-500">
|
||||
Disahkan pada {{ new Date(member.verified_at).toLocaleString('ms-MY') }}
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const profilePublicRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/v/:token',
|
||||
name: 'public-member-profile',
|
||||
component: () => import('./pages/PublicMemberProfile.vue'),
|
||||
meta: { public: true, module: 'profile' },
|
||||
},
|
||||
]
|
||||
|
||||
export const profileLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'profile-overview-2',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { saveAs } from 'file-saver'
|
||||
import axios from 'axios'
|
||||
import { api } from '@/core/services/api'
|
||||
import { buildCardDownloadFileName } from '../utils/member-digital-card.utils'
|
||||
|
||||
export async function downloadMemberDigitalCard(
|
||||
memberNumber: string | number | null | undefined,
|
||||
side: 'depan' | 'belakang',
|
||||
) {
|
||||
try {
|
||||
const { data } = await api.get<Blob>('/v1/profile/digital-card', {
|
||||
params: { side },
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
saveAs(data, buildCardDownloadFileName(memberNumber, side))
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
|
||||
const text = await error.response.data.text()
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string }
|
||||
throw new Error(payload.message ?? 'Gagal menyimpan kad.')
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw parseError
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PublicMemberProfileApiResponse } from '../types/public-member-profile.types'
|
||||
|
||||
export async function getPublicMemberProfile(
|
||||
token: string,
|
||||
): Promise<PublicMemberProfileApiResponse> {
|
||||
const { data } = await api.get<PublicMemberProfileApiResponse>(
|
||||
`/v1/public/members/${encodeURIComponent(token)}`,
|
||||
)
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface PublicMemberProfile {
|
||||
name: string
|
||||
member_number: number | null
|
||||
member_type: string | null
|
||||
status: string
|
||||
image_url: string | null
|
||||
company_name: string | null
|
||||
verified_at: string
|
||||
}
|
||||
|
||||
export interface PublicMemberProfileApiResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
code?: 'public_profile_not_found' | 'public_profile_token_expired'
|
||||
data: PublicMemberProfile | null
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function displayCardValue(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
export function buildCardDownloadFileName(
|
||||
memberNumber: string | number | null | undefined,
|
||||
side: 'depan' | 'belakang',
|
||||
) {
|
||||
const number = memberNumber ?? 'anggota'
|
||||
return `kad-digital-${number}-${side}.png`
|
||||
}
|
||||
|
||||
export function toProxiedStorageUrl(url: string | null | undefined): string | null | undefined {
|
||||
if (!url) return url
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
if (parsed.pathname.startsWith('/storage/')) {
|
||||
return `${parsed.pathname}${parsed.search}`
|
||||
}
|
||||
} catch {
|
||||
// Keep original URL when parsing fails.
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
+2
-96
@@ -5,7 +5,7 @@ import { hasAnyActiveRolePermission } from '@/core/utils/activeRolePermission'
|
||||
import { getRouteRequiredPermissions } from '@/core/utils/routePermission'
|
||||
import { authPublicRoutes, resolvePostAuthRoute } from '@/modules/auth'
|
||||
import { membershipApplicationPublicRoutes, membershipApplicationLayoutRoutes } from '@/modules/membership-application'
|
||||
import { profileLayoutRoutes } from '@/modules/profile'
|
||||
import { profileLayoutRoutes, profilePublicRoutes } from '@/modules/profile'
|
||||
import { pinia } from '@/pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { userLayoutRoutes } from '@/modules/user'
|
||||
@@ -70,11 +70,6 @@ const router = createRouter({
|
||||
name: 'reviews',
|
||||
component: () => import('../views/Reviews.vue'),
|
||||
},
|
||||
{
|
||||
path: 'inbox',
|
||||
name: 'inbox',
|
||||
component: () => import('../views/Inbox.vue'),
|
||||
},
|
||||
{
|
||||
path: 'file-manager',
|
||||
name: 'file-manager',
|
||||
@@ -85,51 +80,11 @@ const router = createRouter({
|
||||
name: 'point-of-sale',
|
||||
component: () => import('../views/PointOfSale.vue'),
|
||||
},
|
||||
{
|
||||
path: 'chat',
|
||||
name: 'chat',
|
||||
component: () => import('../views/Chat.vue'),
|
||||
},
|
||||
{
|
||||
path: 'post',
|
||||
name: 'post',
|
||||
component: () => import('../views/Post.vue'),
|
||||
},
|
||||
{
|
||||
path: 'crud-form',
|
||||
name: 'crud-form',
|
||||
component: () => import('../views/CrudForm.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users-layout-1',
|
||||
name: 'users-layout-1',
|
||||
component: () => import('../views/UsersLayout1.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users-layout-2',
|
||||
name: 'users-layout-2',
|
||||
component: () => import('../views/UsersLayout2.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users-layout-3',
|
||||
name: 'users-layout-3',
|
||||
component: () => import('../views/UsersLayout3.vue'),
|
||||
},
|
||||
{
|
||||
path: 'wizard-layout-1',
|
||||
name: 'wizard-layout-1',
|
||||
component: () => import('../views/WizardLayout1.vue'),
|
||||
},
|
||||
{
|
||||
path: 'wizard-layout-2',
|
||||
name: 'wizard-layout-2',
|
||||
component: () => import('../views/WizardLayout2.vue'),
|
||||
},
|
||||
{
|
||||
path: 'wizard-layout-3',
|
||||
name: 'wizard-layout-3',
|
||||
component: () => import('../views/WizardLayout3.vue'),
|
||||
},
|
||||
{
|
||||
path: 'blog-layout-1',
|
||||
name: 'blog-layout-1',
|
||||
@@ -191,26 +146,6 @@ const router = createRouter({
|
||||
component: DocsLayout,
|
||||
children: [
|
||||
// Documentation
|
||||
{
|
||||
path: "accordion",
|
||||
name: "accordion",
|
||||
component: () => import("../docs/pages/accordion.vue"),
|
||||
},
|
||||
{
|
||||
path: "alert",
|
||||
name: "alert",
|
||||
component: () => import("../docs/pages/alert.vue"),
|
||||
},
|
||||
{
|
||||
path: "avatar",
|
||||
name: "avatar",
|
||||
component: () => import("../docs/pages/avatar.vue"),
|
||||
},
|
||||
{
|
||||
path: "badge",
|
||||
name: "badge",
|
||||
component: () => import("../docs/pages/badge.vue"),
|
||||
},
|
||||
{
|
||||
path: "box",
|
||||
name: "box",
|
||||
@@ -221,26 +156,11 @@ const router = createRouter({
|
||||
name: "breadcrumb",
|
||||
component: () => import("../docs/pages/breadcrumb.vue"),
|
||||
},
|
||||
{
|
||||
path: "button",
|
||||
name: "button",
|
||||
component: () => import("../docs/pages/button.vue"),
|
||||
},
|
||||
{
|
||||
path: "carousel",
|
||||
name: "carousel",
|
||||
component: () => import("../docs/pages/carousel.vue"),
|
||||
},
|
||||
{
|
||||
path: "chart",
|
||||
name: "chart",
|
||||
component: () => import("../docs/pages/chart.vue"),
|
||||
},
|
||||
{
|
||||
path: "checkbox",
|
||||
name: "checkbox",
|
||||
component: () => import("../docs/pages/checkbox.vue"),
|
||||
},
|
||||
{
|
||||
path: "combobox",
|
||||
name: "combobox",
|
||||
@@ -261,16 +181,6 @@ const router = createRouter({
|
||||
name: "field",
|
||||
component: () => import("../docs/pages/field.vue"),
|
||||
},
|
||||
{
|
||||
path: "input",
|
||||
name: "input",
|
||||
component: () => import("../docs/pages/input.vue"),
|
||||
},
|
||||
{
|
||||
path: "map",
|
||||
name: "map",
|
||||
component: () => import("../docs/pages/map.vue"),
|
||||
},
|
||||
{
|
||||
path: "menu",
|
||||
name: "menu",
|
||||
@@ -331,11 +241,6 @@ const router = createRouter({
|
||||
name: "switch",
|
||||
component: () => import("../docs/pages/switch.vue"),
|
||||
},
|
||||
{
|
||||
path: "table",
|
||||
name: "table",
|
||||
component: () => import("../docs/pages/table.vue"),
|
||||
},
|
||||
{
|
||||
path: "tabs",
|
||||
name: "tabs",
|
||||
@@ -362,6 +267,7 @@ const router = createRouter({
|
||||
},
|
||||
...authPublicRoutes,
|
||||
...membershipApplicationPublicRoutes,
|
||||
...profilePublicRoutes,
|
||||
{
|
||||
path: '/error-page',
|
||||
name: 'error-page',
|
||||
|
||||
@@ -18,4 +18,12 @@ export default defineConfig({
|
||||
'@mykopkb/core': fileURLToPath(new URL('./src/components/ui', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/storage': {
|
||||
target: process.env.VITE_API_BASE_URL || 'http://localhost',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user