diff --git a/be/.env.development b/be/.env.development
index b5c5094..74b36c6 100644
--- a/be/.env.development
+++ b/be/.env.development
@@ -95,4 +95,7 @@ API_ALLOWED_USER_AGENTS=
ENABLE_API_KEY_AUTH=true
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
-API_LOG_KEY_USAGE=true
\ No newline at end of file
+API_LOG_KEY_USAGE=true
+
+PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
+FRONTEND_URL=https://mykopkb.koppkb.com
\ No newline at end of file
diff --git a/be/.env.production b/be/.env.production
index 8cfae54..80a9c56 100644
--- a/be/.env.production
+++ b/be/.env.production
@@ -102,4 +102,7 @@ API_ALLOWED_USER_AGENTS=
ENABLE_API_KEY_AUTH=true
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
-API_LOG_KEY_USAGE=true
\ No newline at end of file
+API_LOG_KEY_USAGE=true
+
+PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
+FRONTEND_URL=https://mykopkb.koppkb.com
\ No newline at end of file
diff --git a/be/Modules/Auth/Entities/User.php b/be/Modules/Auth/Entities/User.php
index 3c094b5..ac1422e 100644
--- a/be/Modules/Auth/Entities/User.php
+++ b/be/Modules/Auth/Entities/User.php
@@ -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',
diff --git a/be/Modules/Auth/Http/Controllers/SessionController.php b/be/Modules/Auth/Http/Controllers/SessionController.php
index 34a0652..cfcde33 100644
--- a/be/Modules/Auth/Http/Controllers/SessionController.php
+++ b/be/Modules/Auth/Http/Controllers/SessionController.php
@@ -24,6 +24,7 @@ class SessionController extends Controller
], 401);
}
+ $user->ensurePublicProfileToken();
$user->load(['roles.permissions']);
return response()->json([
diff --git a/be/Modules/Auth/Services/AuthSessionService.php b/be/Modules/Auth/Services/AuthSessionService.php
index bca4178..8cd803c 100644
--- a/be/Modules/Auth/Services/AuthSessionService.php
+++ b/be/Modules/Auth/Services/AuthSessionService.php
@@ -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();
}
diff --git a/be/Modules/Auth/Transformers/UserResource.php b/be/Modules/Auth/Transformers/UserResource.php
index 98518ef..bc89bde 100644
--- a/be/Modules/Auth/Transformers/UserResource.php
+++ b/be/Modules/Auth/Transformers/UserResource.php
@@ -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,
diff --git a/be/Modules/User/Config/config.php b/be/Modules/User/Config/config.php
index 566de5a..57bdafc 100644
--- a/be/Modules/User/Config/config.php
+++ b/be/Modules/User/Config/config.php
@@ -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')),
];
diff --git a/be/Modules/User/Database/Migrations/2026_07_05_100000_add_public_profile_token_to_users_table.php b/be/Modules/User/Database/Migrations/2026_07_05_100000_add_public_profile_token_to_users_table.php
new file mode 100644
index 0000000..51eee6d
--- /dev/null
+++ b/be/Modules/User/Database/Migrations/2026_07_05_100000_add_public_profile_token_to_users_table.php
@@ -0,0 +1,34 @@
+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');
+ });
+ }
+};
diff --git a/be/Modules/User/Database/Migrations/2026_07_05_100001_add_public_profile_token_expires_at_to_users_table.php b/be/Modules/User/Database/Migrations/2026_07_05_100001_add_public_profile_token_expires_at_to_users_table.php
new file mode 100644
index 0000000..cd5ccc4
--- /dev/null
+++ b/be/Modules/User/Database/Migrations/2026_07_05_100001_add_public_profile_token_expires_at_to_users_table.php
@@ -0,0 +1,35 @@
+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');
+ });
+ }
+};
diff --git a/be/Modules/User/Http/Controllers/MemberDigitalCardController.php b/be/Modules/User/Http/Controllers/MemberDigitalCardController.php
new file mode 100644
index 0000000..c0dcabc
--- /dev/null
+++ b/be/Modules/User/Http/Controllers/MemberDigitalCardController.php
@@ -0,0 +1,61 @@
+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);
+ }
+ }
+}
diff --git a/be/Modules/User/Http/Controllers/PublicMemberProfileController.php b/be/Modules/User/Http/Controllers/PublicMemberProfileController.php
new file mode 100644
index 0000000..c25357e
--- /dev/null
+++ b/be/Modules/User/Http/Controllers/PublicMemberProfileController.php
@@ -0,0 +1,37 @@
+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),
+ ]);
+ }
+}
diff --git a/be/Modules/User/Providers/UserServiceProvider.php b/be/Modules/User/Providers/UserServiceProvider.php
index 9c28f9e..e9f7dba 100644
--- a/be/Modules/User/Providers/UserServiceProvider.php
+++ b/be/Modules/User/Providers/UserServiceProvider.php
@@ -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
*/
diff --git a/be/Modules/User/Resources/Views/digital-card/back.blade.php b/be/Modules/User/Resources/Views/digital-card/back.blade.php
new file mode 100644
index 0000000..fd0f376
--- /dev/null
+++ b/be/Modules/User/Resources/Views/digital-card/back.blade.php
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
Imbas untuk sahkan profil anggota MyKOPKB.
+
+
+
No. Anggota
+
{{ $memberNumber }}
+
+
+
+
+
+
+
+
+
diff --git a/be/Modules/User/Resources/Views/digital-card/front.blade.php b/be/Modules/User/Resources/Views/digital-card/front.blade.php
new file mode 100644
index 0000000..9352c56
--- /dev/null
+++ b/be/Modules/User/Resources/Views/digital-card/front.blade.php
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ @if ($avatarDataUri)
+

+ @else
+
{{ $avatarInitials }}
+ @endif
+
+
+
+
+
+
+
+
No. Anggota
+
{{ $memberNumber }}
+
+
+
+
Unit
+
{{ $companyName }}
+
+
+
+
+
+
+
+
diff --git a/be/Modules/User/Routes/api.php b/be/Modules/User/Routes/api.php
index 28c0793..36d1c39 100644
--- a/be/Modules/User/Routes/api.php
+++ b/be/Modules/User/Routes/api.php
@@ -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');
diff --git a/be/Modules/User/Services/MemberDigitalCardService.php b/be/Modules/User/Services/MemberDigitalCardService.php
new file mode 100644
index 0000000..de68afd
--- /dev/null
+++ b/be/Modules/User/Services/MemberDigitalCardService.php
@@ -0,0 +1,141 @@
+prepareViewData($user))->render();
+
+ return $this->makeBrowsershot($html)->screenshot();
+ }
+
+ /**
+ * @return array
+ */
+ 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 : '-';
+ }
+}
diff --git a/be/Modules/User/Services/PublicMemberProfileService.php b/be/Modules/User/Services/PublicMemberProfileService.php
new file mode 100644
index 0000000..2a0c468
--- /dev/null
+++ b/be/Modules/User/Services/PublicMemberProfileService.php
@@ -0,0 +1,25 @@
+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;
+ }
+}
diff --git a/be/Modules/User/Transformers/PublicMemberProfileResource.php b/be/Modules/User/Transformers/PublicMemberProfileResource.php
new file mode 100644
index 0000000..283f9d3
--- /dev/null
+++ b/be/Modules/User/Transformers/PublicMemberProfileResource.php
@@ -0,0 +1,26 @@
+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(),
+ ];
+ }
+}
diff --git a/fe/package.json b/fe/package.json
index 6d389fe..b54979a 100644
--- a/fe/package.json
+++ b/fe/package.json
@@ -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",
diff --git a/fe/pnpm-lock.yaml b/fe/pnpm-lock.yaml
index ef1bc00..c4f6589 100644
--- a/fe/pnpm-lock.yaml
+++ b/fe/pnpm-lock.yaml
@@ -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
diff --git a/fe/src/modules/auth/types/auth.types.ts b/fe/src/modules/auth/types/auth.types.ts
index d985f1c..b46120d 100644
--- a/fe/src/modules/auth/types/auth.types.ts
+++ b/fe/src/modules/auth/types/auth.types.ts
@@ -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
diff --git a/fe/src/modules/profile/components/MemberDigitalCard.vue b/fe/src/modules/profile/components/MemberDigitalCard.vue
new file mode 100644
index 0000000..6e2b4a9
--- /dev/null
+++ b/fe/src/modules/profile/components/MemberDigitalCard.vue
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+ Klik kad untuk paparan penuh
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Klik di luar kad untuk tutup
+
+
+
+
+
diff --git a/fe/src/modules/profile/components/MemberDigitalCardBack.vue b/fe/src/modules/profile/components/MemberDigitalCardBack.vue
new file mode 100644
index 0000000..bc49c86
--- /dev/null
+++ b/fe/src/modules/profile/components/MemberDigitalCardBack.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+ Belakang · Kod QR
+
+
+
+
+
+
![Kod QR profil anggota]()
+
+
+ {{ qrError ? 'Kod QR tidak tersedia.' : 'Memuatkan...' }}
+
+
+
+
+
+
+ Imbas untuk sahkan profil anggota MyKOPKB.
+
+
+
+
+ No. Anggota
+
+
+ {{ displayCardValue(memberNumber) }}
+
+
+
+
+
+
+
+ Koperasi Permodalan Kelantan Berhad (KOPKB)
+
+
+
+
+
diff --git a/fe/src/modules/profile/components/MemberDigitalCardFlip.vue b/fe/src/modules/profile/components/MemberDigitalCardFlip.vue
new file mode 100644
index 0000000..ba3c43f
--- /dev/null
+++ b/fe/src/modules/profile/components/MemberDigitalCardFlip.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
diff --git a/fe/src/modules/profile/components/MemberDigitalCardFront.vue b/fe/src/modules/profile/components/MemberDigitalCardFront.vue
new file mode 100644
index 0000000..7929541
--- /dev/null
+++ b/fe/src/modules/profile/components/MemberDigitalCardFront.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
![]()
+
+ {{ avatarFallback }}
+
+
+
+
+
+
![]()
+
+ Kad Digital
+
+
+
+
+
+
+ No. Anggota
+
+
+ {{ displayCardValue(memberNumber) }}
+
+
+
+
+
+ Unit
+
+
+ {{ displayCardValue(companyName) }}
+
+
+
+
+
+
+
+ {{ memberName || '-' }}
+
+
+ Nama
+
+
+
+
+ {{ displayCardValue(memberType) }}
+
+
+ Jenis Anggota
+
+
+
+
+
+
diff --git a/fe/src/modules/profile/index.ts b/fe/src/modules/profile/index.ts
index 6213d79..b69430f 100644
--- a/fe/src/modules/profile/index.ts
+++ b/fe/src/modules/profile/index.ts
@@ -1,2 +1,2 @@
-export { profileLayoutRoutes } from './routes'
+export { profileLayoutRoutes, profilePublicRoutes } from './routes'
export { profileMenu } from './menu'
diff --git a/fe/src/modules/profile/pages/PublicMemberProfile.vue b/fe/src/modules/profile/pages/PublicMemberProfile.vue
new file mode 100644
index 0000000..60722bb
--- /dev/null
+++ b/fe/src/modules/profile/pages/PublicMemberProfile.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
![MyKOPKB]()
+
Maklumat Anggota
+
+
+
+ Pengesahan gagal
+ {{ error }}
+
+
+
+ Memuatkan maklumat anggota...
+
+
+
+
+
+
Disahkan
+
+ MyKOPKB
+
+
+
+
+
+ {{ avatarFallback }}
+
+
+
+
{{ member.name }}
+
{{ displayValue(member.member_type) }}
+
+
+
+
+
+
+
+
+
No. Anggota
+
+ {{ displayValue(member.member_number) }}
+
+
+
+
+
+
+
+
Syarikat
+
+ {{ displayValue(member.company_name) }}
+
+
+
+
+
+
+
+
Status
+
+ {{ displayValue(member.status) }}
+
+
+
+
+
+ Disahkan pada {{ new Date(member.verified_at).toLocaleString('ms-MY') }}
+
+
+
+
+
+
diff --git a/fe/src/modules/profile/routes.ts b/fe/src/modules/profile/routes.ts
index 22e4a07..5d02d5a 100644
--- a/fe/src/modules/profile/routes.ts
+++ b/fe/src/modules/profile/routes.ts
@@ -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',
diff --git a/fe/src/modules/profile/services/member-digital-card.service.ts b/fe/src/modules/profile/services/member-digital-card.service.ts
new file mode 100644
index 0000000..05c7b92
--- /dev/null
+++ b/fe/src/modules/profile/services/member-digital-card.service.ts
@@ -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('/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
+ }
+}
diff --git a/fe/src/modules/profile/services/public-member-profile.service.ts b/fe/src/modules/profile/services/public-member-profile.service.ts
new file mode 100644
index 0000000..bbc7158
--- /dev/null
+++ b/fe/src/modules/profile/services/public-member-profile.service.ts
@@ -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 {
+ const { data } = await api.get(
+ `/v1/public/members/${encodeURIComponent(token)}`,
+ )
+
+ return data
+}
diff --git a/fe/src/modules/profile/types/public-member-profile.types.ts b/fe/src/modules/profile/types/public-member-profile.types.ts
new file mode 100644
index 0000000..4f694d4
--- /dev/null
+++ b/fe/src/modules/profile/types/public-member-profile.types.ts
@@ -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
+}
diff --git a/fe/src/modules/profile/utils/member-digital-card.utils.ts b/fe/src/modules/profile/utils/member-digital-card.utils.ts
new file mode 100644
index 0000000..81f23ed
--- /dev/null
+++ b/fe/src/modules/profile/utils/member-digital-card.utils.ts
@@ -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
+}
diff --git a/fe/src/router/index.ts b/fe/src/router/index.ts
index c251694..3fe8fe2 100644
--- a/fe/src/router/index.ts
+++ b/fe/src/router/index.ts
@@ -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',
diff --git a/fe/vite.config.ts b/fe/vite.config.ts
index 8717bea..2f64aec 100644
--- a/fe/vite.config.ts
+++ b/fe/vite.config.ts
@@ -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,
+ },
+ },
+ },
})