Skip to main content

Vue.js integration: reusable components

This page provides production-ready wrapper components for the Weavr SDK. For the underlying patterns these components are built on, see Component patterns.

Reusable wrapper components

For production applications, create reusable wrapper components that encapsulate SDK logic. This approach provides a cleaner API, reduces boilerplate, and makes components easier to test and maintain.

Available wrapper components

The following wrapper components cover all Weavr SDK integration patterns:

Input + Tokenize Pattern:

ComponentPurpose
WeavrPasswordInputPassword input with tokenize
WeavrPasscodeInputPasscode input (6-digit) with tokenize
WeavrConfirmPasswordPassword + confirm password pair

Authenticated Display Pattern:

ComponentPurpose
WeavrCardNumberDisplay card number (requires auth)
WeavrCvvDisplay CVV (requires auth)
WeavrCardPinDisplay card PIN (requires auth)

Authenticated Input Pattern:

ComponentPurpose
WeavrCardPinCaptureCapture/set card PIN (requires auth)

Full-Screen Verification Pattern:

ComponentPurpose
WeavrKycConsumer KYC verification (requires auth)
WeavrKybCorporate KYB verification (requires auth)
WeavrDirectorKycDirector KYC verification (NO auth)

Provider:

ComponentPurpose
WeavrAuthProviderAuthentication context provider
tip

All authenticated components can be used standalone (pass authToken prop) or within a WeavrAuthProvider which handles authentication once for all children.

Organize wrapper components by their integration pattern:

src/components/weavr/
├── input-tokenize/
│ ├── WeavrPasswordInput.vue
│ ├── WeavrPasscodeInput.vue
│ ├── WeavrConfirmPassword.vue
│ └── index.ts
├── authenticated-display/
│ ├── WeavrCardNumber.vue
│ ├── WeavrCvv.vue
│ ├── WeavrCardPin.vue
│ └── index.ts
├── authenticated-input/
│ ├── WeavrCardPinCapture.vue
│ └── index.ts
├── verification/
│ ├── WeavrKyc.vue
│ ├── WeavrKyb.vue
│ ├── WeavrDirectorKyc.vue
│ └── index.ts
├── provider/
│ ├── WeavrAuthProvider.vue
│ └── index.ts
├── composables/
│ ├── useWeavrAuth.ts
│ └── index.ts
└── index.ts

The root index.ts re-exports all components for convenient imports:

// src/components/weavr/index.ts
export * from "./input-tokenize";
export * from "./authenticated-display";
export * from "./authenticated-input";
export * from "./verification";
export * from "./provider";
export * from "./composables";

Password input wrapper

<!-- src/components/weavr/WeavrPasswordInput.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";

const props = withDefaults(
defineProps<{
name?: string;
placeholder?: string;
maxlength?: number;
disabled?: boolean;
style?: Record<string, any>;
}>(),
{
name: "password",
placeholder: "Enter password",
maxlength: 50,
disabled: false,
}
);

const emit = defineEmits<{
(e: "ready"): void;
(e: "change", isValid: boolean): void;
(e: "submit"): void;
(e: "error", error: Error): void;
}>();

const containerRef = ref<HTMLElement | null>(null);
const isReady = ref(false);
const hasError = ref(false);

let form: any = null;
let input: any = null;

// Expose tokenize method to parent
const tokenize = (): Promise<string> => {
return new Promise((resolve, reject) => {
if (!form) {
reject(new Error("Form not initialized"));
return;
}
form.tokenize((tokens: Record<string, string>) => {
const token = tokens[props.name];
if (token) {
resolve(token);
} else {
reject(
new Error("No token received - input may be empty or invalid")
);
}
});
});
};

defineExpose({ tokenize });

onMounted(() => {
if (!window.OpcUxSecureClient) {
hasError.value = true;
emit("error", new Error("Weavr SDK not loaded"));
return;
}

try {
form = window.OpcUxSecureClient.form();
input = form.input(props.name, "password", {
placeholder: props.placeholder,
maxlength: props.maxlength,
disabled: props.disabled,
style: props.style,
});

input.on("ready", () => {
isReady.value = true;
emit("ready");
});

input.on("change", (event: any) => {
emit("change", event?.valid ?? false);
});

input.on("submit", () => {
emit("submit");
});

input.mount(containerRef.value);
} catch (e) {
hasError.value = true;
emit("error", e instanceof Error ? e : new Error("Unknown error"));
}
});

onUnmounted(() => {
try {
if (input && typeof input.unmount === "function") {
input.unmount();
}
} catch (e) {
console.error("Error unmounting WeavrPasswordInput:", e);
}
});
</script>

<template>
<div class="weavr-password-input">
<div v-if="hasError" class="weavr-error">Failed to load secure input</div>
<div v-else ref="containerRef" class="weavr-input-container"></div>
</div>
</template>

<style scoped>
.weavr-input-container {
min-height: 40px;
}
</style>

Usage:

<script setup lang="ts">
import { ref } from "vue";
import WeavrPasswordInput from "@/components/weavr/WeavrPasswordInput.vue";

const passwordInput = ref<InstanceType<typeof WeavrPasswordInput> | null>(
null
);
const isValid = ref(false);

const handleSubmit = async () => {
try {
const token = await passwordInput.value?.tokenize();
console.log("Password token:", token);
// Send token to your server
} catch (e) {
console.error("Tokenization failed:", e);
}
};
</script>

<template>
<form @submit.prevent="handleSubmit">
<WeavrPasswordInput
ref="passwordInput"
placeholder="Enter your password"
@change="isValid = $event"
@submit="handleSubmit"
/>
<button type="submit" :disabled="!isValid">Login</button>
</form>
</template>

Card number display wrapper

<!-- src/components/weavr/WeavrCardNumber.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, inject } from "vue";

const props = defineProps<{
token: string;
style?: Record<string, any>;
}>();

const emit = defineEmits<{
(e: "ready"): void;
(e: "error", error: Error): void;
}>();

// Inject auth state from provider (if available)
const weavrAuth = inject<{ isAuthenticated: boolean } | null>(
"weavrAuth",
null
);

const containerRef = ref<HTMLElement | null>(null);
const isLoading = ref(true);
const error = ref<string | null>(null);

let span: any = null;

const mountSpan = () => {
try {
span = window.OpcUxSecureClient.span("cardNumber", props.token, {
style: props.style || {
fontSize: "16px",
fontFamily: "monospace",
letterSpacing: "2px",
},
});
span.mount(containerRef.value);
isLoading.value = false;
emit("ready");
} catch (e) {
error.value = "Failed to display card number";
emit("error", e instanceof Error ? e : new Error("Unknown error"));
}
};

onMounted(() => {
if (!window.OpcUxSecureClient) {
error.value = "SDK not loaded";
emit("error", new Error("Weavr SDK not loaded"));
return;
}

// If using provider, auth is already handled
if (weavrAuth?.isAuthenticated) {
mountSpan();
} else {
// Component used standalone - caller must have called associate() already
mountSpan();
}
});

onUnmounted(() => {
try {
if (span && typeof span.unmount === "function") {
span.unmount();
}
} catch (e) {
console.error("Error unmounting WeavrCardNumber:", e);
}
});
</script>

<template>
<div class="weavr-card-number">
<span v-if="isLoading" class="loading">Loading...</span>
<span v-else-if="error" class="error">{{ error }}</span>
<span v-else ref="containerRef"></span>
</div>
</template>

CVV display wrapper

<!-- src/components/weavr/WeavrCvv.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, inject } from "vue";

const props = defineProps<{
token: string;
style?: Record<string, any>;
}>();

const emit = defineEmits<{
(e: "ready"): void;
(e: "error", error: Error): void;
}>();

const weavrAuth = inject<{ isAuthenticated: boolean } | null>(
"weavrAuth",
null
);

const containerRef = ref<HTMLElement | null>(null);
const isLoading = ref(true);
const error = ref<string | null>(null);

let span: any = null;

onMounted(() => {
if (!window.OpcUxSecureClient) {
error.value = "SDK not loaded";
emit("error", new Error("Weavr SDK not loaded"));
return;
}

try {
span = window.OpcUxSecureClient.span("cvv", props.token, {
style: props.style || { fontSize: "16px", fontFamily: "monospace" },
});
span.mount(containerRef.value);
isLoading.value = false;
emit("ready");
} catch (e) {
error.value = "Failed to display CVV";
emit("error", e instanceof Error ? e : new Error("Unknown error"));
}
});

onUnmounted(() => {
try {
if (span && typeof span.unmount === "function") {
span.unmount();
}
} catch (e) {
console.error("Error unmounting WeavrCvv:", e);
}
});
</script>

<template>
<div class="weavr-cvv">
<span v-if="isLoading" class="loading">...</span>
<span v-else-if="error" class="error">{{ error }}</span>
<span v-else ref="containerRef"></span>
</div>
</template>

Passcode input wrapper

For 6-digit passcode inputs (used in registration/login flows):

<!-- src/components/weavr/WeavrPasscodeInput.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";

const props = withDefaults(
defineProps<{
name?: string;
placeholder?: string;
maxlength?: number;
disabled?: boolean;
style?: Record<string, any>;
}>(),
{
name: "passcode",
placeholder: "Enter 6-digit passcode",
maxlength: 6,
disabled: false,
}
);

const emit = defineEmits<{
(e: "ready"): void;
(e: "change", isValid: boolean): void;
(e: "submit"): void;
(e: "error", error: Error): void;
}>();

const containerRef = ref<HTMLElement | null>(null);
const hasError = ref(false);

let form: any = null;
let input: any = null;

const tokenize = (): Promise<string> => {
return new Promise((resolve, reject) => {
if (!form) {
reject(new Error("Form not initialized"));
return;
}
form.tokenize((tokens: Record<string, string>) => {
const token = tokens[props.name];
if (token) {
resolve(token);
} else {
reject(new Error("No token received"));
}
});
});
};

defineExpose({ tokenize });

onMounted(() => {
if (!window.OpcUxSecureClient) {
hasError.value = true;
emit("error", new Error("Weavr SDK not loaded"));
return;
}

try {
form = window.OpcUxSecureClient.form();
input = form.input(props.name, "passCode", {
placeholder: props.placeholder,
maxlength: props.maxlength,
disabled: props.disabled,
style: props.style || {
base: {
fontSize: "20px",
letterSpacing: "8px",
textAlign: "center",
fontFamily: "monospace",
},
},
});

input.on("ready", () => emit("ready"));
input.on("change", (event: any) => emit("change", event?.valid ?? false));
input.on("submit", () => emit("submit"));
input.mount(containerRef.value);
} catch (e) {
hasError.value = true;
emit("error", e instanceof Error ? e : new Error("Unknown error"));
}
});

onUnmounted(() => {
try {
if (input && typeof input.unmount === "function") {
input.unmount();
}
} catch (e) {
console.error("Error unmounting:", e);
}
});
</script>

<template>
<div class="weavr-passcode-input">
<div v-if="hasError" class="weavr-error">Failed to load secure input</div>
<div v-else ref="containerRef" class="weavr-input-container"></div>
</div>
</template>

Card PIN display wrapper

For displaying card PIN (requires authentication):

<!-- src/components/weavr/WeavrCardPin.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, inject } from "vue";

const props = defineProps<{
token: string;
style?: Record<string, any>;
}>();

const emit = defineEmits<{
(e: "ready"): void;
(e: "error", error: Error): void;
}>();

const weavrAuth = inject<{ isAuthenticated: boolean } | null>(
"weavrAuth",
null
);

const containerRef = ref<HTMLElement | null>(null);
const isLoading = ref(true);
const error = ref<string | null>(null);

let span: any = null;

onMounted(() => {
if (!window.OpcUxSecureClient) {
error.value = "SDK not loaded";
emit("error", new Error("Weavr SDK not loaded"));
return;
}

try {
span = window.OpcUxSecureClient.span("cardPin", props.token, {
style: props.style || {
fontSize: "16px",
fontFamily: "monospace",
letterSpacing: "4px",
},
});
span.mount(containerRef.value);
isLoading.value = false;
emit("ready");
} catch (e) {
error.value = "Failed to display PIN";
emit("error", e instanceof Error ? e : new Error("Unknown error"));
}
});

onUnmounted(() => {
try {
if (span && typeof span.unmount === "function") {
span.unmount();
}
} catch (e) {
console.error("Error unmounting:", e);
}
});
</script>

<template>
<div class="weavr-card-pin">
<span v-if="isLoading" class="loading">****</span>
<span v-else-if="error" class="error">{{ error }}</span>
<span v-else ref="containerRef"></span>
</div>
</template>

Consumer KYC wrapper

Full-screen identity verification for consumers (requires authentication):

<!-- src/components/weavr/WeavrKyc.vue -->
<script setup lang="ts">
import { ref, onMounted, inject } from "vue";

const props = withDefaults(
defineProps<{
reference: string; // From API: POST /consumers/kyc
authToken?: string; // Optional if using WeavrAuthProvider
lang?: string;
containerId?: string;
}>(),
{
lang: "en",
containerId: "weavr-kyc-container",
}
);

const emit = defineEmits<{
(e: "ready"): void;
(e: "submitted"): void;
(e: "approved"): void;
(e: "rejected"): void;
(e: "message", message: string, additionalInfo: any): void;
(e: "error", error: Error): void;
}>();

const weavrAuth = inject<{
isAuthenticated: boolean;
authToken: string;
} | null>("weavrAuth", null);

const isLoading = ref(true);
const hasError = ref(false);
const errorMessage = ref<string | null>(null);

const initializeKyc = () => {
try {
window.OpcUxSecureClient.consumer_kyc().init({
selector: `#${props.containerId}`,
reference: props.reference,
lang: props.lang,
onMessage: (message: string, additionalInfo: any) => {
emit("message", message, additionalInfo);
if (message === "kycSubmitted") emit("submitted");
else if (message === "kycApproved") emit("approved");
else if (message === "kycRejected") emit("rejected");
},
onError: (error: string) => {
hasError.value = true;
errorMessage.value = error;
emit("error", new Error(error));
},
});
isLoading.value = false;
emit("ready");
} catch (e) {
hasError.value = true;
errorMessage.value = e instanceof Error ? e.message : "Unknown error";
emit("error", e instanceof Error ? e : new Error("Unknown error"));
}
};

onMounted(() => {
if (!window.OpcUxSecureClient) {
hasError.value = true;
errorMessage.value = "Weavr SDK not loaded";
emit("error", new Error("SDK not loaded"));
return;
}

if (weavrAuth?.isAuthenticated) {
initializeKyc();
} else if (props.authToken) {
window.OpcUxSecureClient.associate(
`Bearer ${props.authToken}`,
() => initializeKyc(),
(e: Error) => {
hasError.value = true;
errorMessage.value = "Authentication failed";
emit("error", e);
}
);
} else {
hasError.value = true;
errorMessage.value = "Auth token required";
emit(
"error",
new Error(
"Auth token required - use WeavrAuthProvider or pass authToken prop"
)
);
}
});
</script>

<template>
<div class="weavr-kyc">
<div v-if="isLoading" class="kyc-loading">
<slot name="loading">Loading verification...</slot>
</div>
<div v-if="hasError" class="kyc-error">
<slot name="error" :error="errorMessage">{{ errorMessage }}</slot>
</div>
<div :id="containerId" :class="{ hidden: isLoading || hasError }"></div>
</div>
</template>

<style scoped>
.weavr-kyc {
min-height: 500px;
}
.kyc-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.kyc-error {
padding: 1rem;
color: #dc3545;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
.hidden {
display: none;
}
</style>

Director KYC wrapper

Director identity verification accessed via email link. Does not require authentication:

<!-- src/components/weavr/WeavrDirectorKyc.vue -->
<script setup lang="ts">
import { ref, onMounted } from "vue";

const props = withDefaults(
defineProps<{
reference: string; // From email link
lang?: string;
containerId?: string;
}>(),
{
lang: "en",
containerId: "weavr-director-kyc-container",
}
);

const emit = defineEmits<{
(e: "ready"): void;
(e: "submitted"): void;
(e: "approved"): void;
(e: "rejected"): void;
(e: "message", message: string, additionalInfo: any): void;
(e: "error", error: Error): void;
}>();

const isLoading = ref(true);
const hasError = ref(false);
const errorMessage = ref<string | null>(null);

onMounted(() => {
if (!window.OpcUxSecureClient) {
hasError.value = true;
errorMessage.value = "Weavr SDK not loaded";
emit("error", new Error("SDK not loaded"));
return;
}

try {
// Director KYC does NOT require associate()
window.OpcUxSecureClient.kyc().init(
`#${props.containerId}`,
{ reference: props.reference },
(message: string, additionalInfo: any) => {
emit("message", message, additionalInfo);
if (message === "kycSubmitted") emit("submitted");
else if (message === "kycApproved") emit("approved");
else if (message === "kycRejected") emit("rejected");
else if (message === "error") {
hasError.value = true;
errorMessage.value = additionalInfo;
emit("error", new Error(additionalInfo));
}
},
{ lang: props.lang }
);
isLoading.value = false;
emit("ready");
} catch (e) {
hasError.value = true;
errorMessage.value = e instanceof Error ? e.message : "Unknown error";
emit("error", e instanceof Error ? e : new Error("Unknown error"));
}
});
</script>

<template>
<div class="weavr-director-kyc">
<div v-if="isLoading" class="kyc-loading">
<slot name="loading">Loading verification...</slot>
</div>
<div v-if="hasError" class="kyc-error">
<slot name="error" :error="errorMessage">{{ errorMessage }}</slot>
</div>
<div :id="containerId" :class="{ hidden: isLoading || hasError }"></div>
</div>
</template>
Director KYC exception

Unlike other authenticated components, WeavrDirectorKyc does not require authentication via associate(). Directors receive a verification link via email containing the reference token.

Authentication provider pattern

For pages displaying multiple authenticated components (card details, PIN, etc.), use a provider component to handle authentication once and share the auth state with all children.

Authentication provider

<!-- src/components/weavr/WeavrAuthProvider.vue -->
<script setup lang="ts">
import { ref, provide, onMounted, watch } from "vue";

const props = defineProps<{
authToken: string;
}>();

const emit = defineEmits<{
(e: "authenticated"): void;
(e: "error", error: Error): void;
}>();

const isAuthenticated = ref(false);
const isLoading = ref(true);
const error = ref<string | null>(null);

// Provide auth state to all descendant components
provide("weavrAuth", {
isAuthenticated,
authToken: props.authToken,
});

const authenticate = () => {
if (!window.OpcUxSecureClient) {
error.value = "Weavr SDK not loaded";
isLoading.value = false;
emit("error", new Error("SDK not loaded"));
return;
}

if (!props.authToken) {
error.value = "Auth token is required";
isLoading.value = false;
emit("error", new Error("Auth token is required"));
return;
}

window.OpcUxSecureClient.associate(
`Bearer ${props.authToken}`,
() => {
isAuthenticated.value = true;
isLoading.value = false;
emit("authenticated");
},
(e: Error) => {
error.value = "Authentication failed";
isLoading.value = false;
emit("error", e);
}
);
};

onMounted(() => {
authenticate();
});

// Re-authenticate if token changes
watch(
() => props.authToken,
(newToken, oldToken) => {
if (newToken && newToken !== oldToken) {
isAuthenticated.value = false;
isLoading.value = true;
error.value = null;
authenticate();
}
}
);
</script>

<template>
<div class="weavr-auth-provider">
<div v-if="isLoading" class="auth-loading">
<slot name="loading">Authenticating...</slot>
</div>
<div v-else-if="error" class="auth-error">
<slot name="error" :error="error">{{ error }}</slot>
</div>
<slot v-else></slot>
</div>
</template>

Using the provider with card components

<script setup lang="ts">
import { ref, onMounted } from "vue";
import WeavrAuthProvider from "@/components/weavr/WeavrAuthProvider.vue";
import WeavrCardNumber from "@/components/weavr/WeavrCardNumber.vue";
import WeavrCvv from "@/components/weavr/WeavrCvv.vue";

const authToken = ref("");
const cardNumberToken = ref("");
const cvvToken = ref("");

onMounted(async () => {
// Fetch tokens from your API
const response = await fetch("/api/card/tokens");
const data = await response.json();

authToken.value = data.authToken;
cardNumberToken.value = data.cardNumberToken;
cvvToken.value = data.cvvToken;
});
</script>

<template>
<WeavrAuthProvider
v-if="authToken"
:auth-token="authToken"
@error="console.error('Auth failed:', $event)"
>
<template #loading>
<div class="skeleton">Loading card details...</div>
</template>

<template #error="{ error }">
<div class="error-message">
Failed to authenticate: {{ error }}
<button @click="$router.push('/login')">Re-login</button>
</div>
</template>

<div class="card-details">
<div class="field">
<label>Card Number</label>
<WeavrCardNumber :token="cardNumberToken" />
</div>

<div class="field">
<label>CVV</label>
<WeavrCvv :token="cvvToken" />
</div>
</div>
</WeavrAuthProvider>
</template>

Composable for consuming auth context

Create a composable to access auth state in deeply nested components:

// src/composables/useWeavrAuth.ts
import { inject, computed } from "vue";

interface WeavrAuthContext {
isAuthenticated: boolean;
authToken: string;
}

export function useWeavrAuth() {
const context = inject<WeavrAuthContext | null>("weavrAuth", null);

if (!context) {
console.warn(
"useWeavrAuth must be used within a WeavrAuthProvider component"
);
}

return {
isAuthenticated: computed(() => context?.isAuthenticated ?? false),
authToken: computed(() => context?.authToken ?? ""),
hasProvider: computed(() => context !== null),
};
}

Usage in a nested component:

<script setup lang="ts">
import { useWeavrAuth } from "@/composables/useWeavrAuth";

const { isAuthenticated, hasProvider } = useWeavrAuth();

// Component can check if it's within a provider
if (!hasProvider.value) {
console.warn("This component should be used within WeavrAuthProvider");
}
</script>

Full example with wrapper components

Here's a complete card details page using wrapper components and the provider:

<!-- src/views/CardDetailsView.vue -->
<script setup lang="ts">
import { ref, onMounted } from "vue";
import WeavrAuthProvider from "@/components/weavr/WeavrAuthProvider.vue";
import WeavrCardNumber from "@/components/weavr/WeavrCardNumber.vue";
import WeavrCvv from "@/components/weavr/WeavrCvv.vue";

interface CardData {
authToken: string;
cardNumberToken: string;
cvvToken: string;
cardholderName: string;
expiryDate: string;
}

const cardData = ref<CardData | null>(null);
const isLoading = ref(true);
const fetchError = ref<string | null>(null);

onMounted(async () => {
try {
const response = await fetch("/api/cards/current");
if (!response.ok) throw new Error("Failed to fetch card data");
cardData.value = await response.json();
} catch (e) {
fetchError.value = e instanceof Error ? e.message : "Unknown error";
} finally {
isLoading.value = false;
}
});
</script>

<template>
<div class="card-details-view">
<h1>Card Details</h1>

<div v-if="isLoading" class="loading">Loading card information...</div>

<div v-else-if="fetchError" class="error">{{ fetchError }}</div>

<WeavrAuthProvider
v-else-if="cardData"
:auth-token="cardData.authToken"
@authenticated="console.log('Ready to display card')"
@error="console.error('Auth error:', $event)"
>
<template #loading>
<div class="card-skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</template>

<div class="card-container">
<div class="card-front">
<div class="card-number-row">
<WeavrCardNumber :token="cardData.cardNumberToken" />
</div>

<div class="card-info-row">
<div class="cardholder">
<label>Cardholder</label>
<span>{{ cardData.cardholderName }}</span>
</div>
<div class="expiry">
<label>Expires</label>
<span>{{ cardData.expiryDate }}</span>
</div>
<div class="cvv">
<label>CVV</label>
<WeavrCvv :token="cardData.cvvToken" />
</div>
</div>
</div>
</div>
</WeavrAuthProvider>
</div>
</template>

<style scoped>
.card-container {
max-width: 400px;
margin: 2rem auto;
}

.card-front {
background: linear-gradient(135deg, #1a1a2e, #16213e);
color: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}

.card-number-row {
font-size: 1.5rem;
letter-spacing: 2px;
margin-bottom: 2rem;
}

.card-info-row {
display: flex;
gap: 2rem;
}

.card-info-row label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 0.25rem;
}
</style>

Next steps