Add new payment logo SVGs for Discover, JCB, Maestro, Mastercard, UnionPay, and Visa

- Added discover.svg for Discover card logo.
- Added jcb.svg for JCB card logo.
- Added maestro.svg for Maestro card logo.
- Added mastercard.svg for Mastercard logo.
- Added unionpay.svg for UnionPay logo.
- Added visa.svg for Visa card logo.
This commit is contained in:
shuaiplus
2026-05-10 23:33:41 +08:00
parent 7c58282e42
commit 9e39161fc7
22 changed files with 811 additions and 7 deletions
+4
View File
@@ -9,6 +9,7 @@ import {
MOBILE_LAYOUT_QUERY,
VAULT_LIST_OVERSCAN,
VAULT_LIST_ROW_HEIGHT,
cardListSubtitle,
FOLDER_SORT_STORAGE_KEY,
VAULT_SORT_STORAGE_KEY,
cipherTypeKey,
@@ -501,6 +502,9 @@ const folderName = useCallback((id: string | null | undefined): string => {
if (Number(cipher.type || 1) === 1) {
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
}
if (Number(cipher.type || 1) === 3) {
return cardListSubtitle(cipher);
}
return cipherTypeLabel(Number(cipher.type || 1));
}, [cipherMetaById]);
@@ -5,10 +5,12 @@ import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
CardBrandIcon,
TOTP_PERIOD_SECONDS,
TOTP_RING_CIRCUMFERENCE,
VaultListIcon,
copyToClipboard,
displayCardBrand,
formatAttachmentSize,
formatHistoryTime,
formatTotp,
@@ -246,7 +248,13 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<h4>{t('txt_card_details')}</h4>
<div className="kv-line"><span>{t('txt_cardholder_name')}</span><strong>{props.selectedCipher.card.decCardholderName || ''}</strong></div>
<div className="kv-line"><span>{t('txt_number')}</span><strong>{props.selectedCipher.card.decNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_brand')}</span><strong>{props.selectedCipher.card.decBrand || ''}</strong></div>
<div className="kv-line">
<span>{t('txt_brand')}</span>
<strong className="card-brand-detail">
<CardBrandIcon brand={props.selectedCipher.card.decBrand} />
{displayCardBrand(props.selectedCipher.card.decBrand)}
</strong>
</div>
<div className="kv-line"><span>{t('txt_expiry')}</span><strong>{`${props.selectedCipher.card.decExpMonth || ''}/${props.selectedCipher.card.decExpYear || ''}`}</strong></div>
<div className="kv-line"><span>{t('txt_security_code')}</span><strong>{props.selectedCipher.card.decCode || ''}</strong></div>
</div>
+39 -2
View File
@@ -5,13 +5,17 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
import { cardBrand } from '@/lib/import-format-shared';
import {
CARD_BRAND_OPTIONS,
CardBrandIcon,
cipherTypeLabel,
createEmptyLoginUri,
formatAttachmentSize,
formatHistoryTime,
getCreateTypeOptions,
getWebsiteMatchOptions,
normalizeCardBrand,
toBooleanFieldValue,
} from '@/components/vault/vault-page-helpers';
@@ -126,6 +130,10 @@ function WebsiteRow(props: WebsiteRowProps) {
export default function VaultEditor(props: VaultEditorProps) {
const createTypeOptions = getCreateTypeOptions();
const normalizedDraftCardBrand = normalizeCardBrand(props.draft.cardBrand);
const cardBrandOptions = normalizedDraftCardBrand && !CARD_BRAND_OPTIONS.includes(normalizedDraftCardBrand as any)
? [...CARD_BRAND_OPTIONS, normalizedDraftCardBrand]
: CARD_BRAND_OPTIONS;
const totpQrVideoRef = useRef<HTMLVideoElement | null>(null);
const totpQrFileRef = useRef<HTMLInputElement | null>(null);
const totpQrStreamRef = useRef<MediaStream | null>(null);
@@ -435,8 +443,37 @@ export default function VaultEditor(props: VaultEditorProps) {
<h4>{t('txt_card_details')}</h4>
<div className="field-grid">
<label className="field"><span>{t('txt_cardholder_name')}</span><input className="input" value={props.draft.cardholderName} onInput={(e) => props.onUpdateDraft({ cardholderName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_number')}</span><input className="input" value={props.draft.cardNumber} onInput={(e) => props.onUpdateDraft({ cardNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_brand')}</span><input className="input" value={props.draft.cardBrand} onInput={(e) => props.onUpdateDraft({ cardBrand: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field">
<span>{t('txt_number')}</span>
<input
className="input"
value={props.draft.cardNumber}
onInput={(e) => {
const value = (e.currentTarget as HTMLInputElement).value;
const detectedBrand = normalizeCardBrand(cardBrand(value) || '');
props.onUpdateDraft({
cardNumber: value,
...(props.draft.cardBrand ? {} : { cardBrand: detectedBrand }),
});
}}
/>
</label>
<label className="field">
<span>{t('txt_brand')}</span>
<div className="card-brand-select-row">
<CardBrandIcon brand={normalizedDraftCardBrand} />
<select
className="input card-brand-select"
value={normalizedDraftCardBrand}
onInput={(e) => props.onUpdateDraft({ cardBrand: (e.currentTarget as HTMLSelectElement).value })}
>
<option value="">{t('txt_select')}</option>
{cardBrandOptions.map((brand) => (
<option key={brand} value={brand}>{brand}</option>
))}
</select>
</div>
</label>
<label className="field"><span>{t('txt_security_code_cvv')}</span><input className="input" value={props.draft.cardCode} onInput={(e) => props.onUpdateDraft({ cardCode: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_expiry_month')}</span><input className="input" value={props.draft.cardExpMonth} onInput={(e) => props.onUpdateDraft({ cardExpMonth: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_expiry_year')}</span><input className="input" value={props.draft.cardExpYear} onInput={(e) => props.onUpdateDraft({ cardExpYear: (e.currentTarget as HTMLInputElement).value })} /></label>
@@ -92,7 +92,7 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
onInput={(e) => props.onToggleSelected(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
/>
<button type="button" className="row-main" onClick={() => props.onSelectCipher(props.cipher.id)}>
<div className="list-icon-wrap">
<div className={`list-icon-wrap ${Number(props.cipher.type || 1) === 3 ? 'card-list-icon-wrap' : ''}`}>
<VaultListIcon cipher={props.cipher} />
</div>
<div className="list-text">
@@ -28,6 +28,89 @@ interface TypeOption {
label: string;
}
export const CARD_BRAND_OPTIONS = [
'Visa',
'Mastercard',
'American Express',
'Discover',
'Diners Club',
'JCB',
'Maestro',
'UnionPay',
'RuPay',
] as const;
type CardBrand = typeof CARD_BRAND_OPTIONS[number];
const CARD_BRAND_ALIASES: Record<string, CardBrand> = {
amex: 'American Express',
'american express': 'American Express',
americanexpress: 'American Express',
discover: 'Discover',
diners: 'Diners Club',
'diners club': 'Diners Club',
dinersclub: 'Diners Club',
jcb: 'JCB',
maestro: 'Maestro',
mastercard: 'Mastercard',
master: 'Mastercard',
rupay: 'RuPay',
unionpay: 'UnionPay',
'union pay': 'UnionPay',
visa: 'Visa',
};
const CARD_BRAND_LOGO_SLUGS: Partial<Record<CardBrand, string>> = {
'American Express': 'american-express',
'Diners Club': 'diners',
Discover: 'discover',
JCB: 'jcb',
Maestro: 'maestro',
Mastercard: 'mastercard',
UnionPay: 'unionpay',
Visa: 'visa',
};
export function normalizeCardBrand(value: string | null | undefined): string {
const normalized = String(value || '').trim();
if (!normalized) return '';
return CARD_BRAND_ALIASES[normalized.toLowerCase().replace(/\s+/g, ' ')] || normalized;
}
export function displayCardBrand(value: string | null | undefined): string {
return normalizeCardBrand(value);
}
export function cardLast4(value: string | null | undefined): string {
const digits = String(value || '').replace(/\D/g, '');
return digits.length >= 4 ? digits.slice(-4) : '';
}
export function cardListSubtitle(cipher: Cipher): string {
const brand = displayCardBrand(cipher.card?.decBrand ?? cipher.card?.brand);
const last4 = cardLast4(cipher.card?.decNumber ?? cipher.card?.number);
if (brand && last4) return `${brand}, *${last4}`;
if (brand) return brand;
if (last4) return `*${last4}`;
return cipherTypeLabel(3);
}
export function CardBrandIcon({ brand }: { brand?: string | null }) {
const display = displayCardBrand(brand);
const key = display.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'generic';
const label = display || t('txt_card');
const logoSlug = CARD_BRAND_LOGO_SLUGS[display as CardBrand];
return (
<span className={`card-brand-icon card-brand-${key}`} aria-label={label} title={label}>
{logoSlug ? (
<img src={`/payment-logos/cards/${logoSlug}.svg`} alt="" loading="lazy" decoding="async" />
) : (
<CreditCard size={18} />
)}
</span>
);
}
export function getCreateTypeOptions(): TypeOption[] {
return [
{ type: 1, label: t('txt_login') },
@@ -323,7 +406,7 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
if (cipher.card) {
draft.cardholderName = cipher.card.decCardholderName || '';
draft.cardNumber = cipher.card.decNumber || '';
draft.cardBrand = cipher.card.decBrand || '';
draft.cardBrand = normalizeCardBrand(cipher.card.decBrand || '');
draft.cardExpMonth = cipher.card.decExpMonth || '';
draft.cardExpYear = cipher.card.decExpYear || '';
draft.cardCode = cipher.card.decCode || '';
@@ -425,6 +508,9 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
}
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
if (Number(cipher.type || 1) === 3) {
return <CardBrandIcon brand={cipher.card?.decBrand ?? cipher.card?.brand} />;
}
return <WebsiteIcon cipher={cipher} fallback={<TypeIcon type={Number(cipher.type || 1)} />} />;
}