DONE: digital card with profile display: WIP: membership condition on letter, is_first_time column in users table

This commit is contained in:
ISMAIL MASSERAN
2026-07-06 09:43:58 +08:00
parent 61345e9cf0
commit ac8f00175a
34 changed files with 1662 additions and 99 deletions
+4 -1
View File
@@ -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
View File
@@ -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
+50
View File
@@ -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,
+3
View File
@@ -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')),
];
@@ -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');
});
}
};
@@ -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>
+10
View File
@@ -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(),
];
}
}
+2
View File
@@ -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",
+164
View File
@@ -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
+1
View File
@@ -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 -1
View File
@@ -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>
+9
View File
@@ -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
View File
@@ -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',
+8
View File
@@ -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,
},
},
},
})