diff --git a/apps/learn-card-app/src/Routes.tsx b/apps/learn-card-app/src/Routes.tsx index 88597520e3..faa41dcc7a 100644 --- a/apps/learn-card-app/src/Routes.tsx +++ b/apps/learn-card-app/src/Routes.tsx @@ -101,6 +101,15 @@ const SubmissionForm = lazyWithRetry( const AppStoreAdminDashboard = lazyWithRetry( () => import('./pages/appStoreAdmin/AdminDashboard') ); +const IntegrationHub = lazyWithRetry( + () => import('./pages/appStoreDeveloper/guides/IntegrationHub') +); +const GuidePage = lazyWithRetry( + () => import('./pages/appStoreDeveloper/guides/GuidePage') +); +const PartnerOnboardingWizard = lazyWithRetry( + () => import('./pages/appStoreDeveloper/partner-onboarding/PartnerOnboardingWizard') +); // import ExternalConsentFlowDoor from './pages/consentFlow/ExternalConsentFlowDoor'; // import CustomWallet from './pages/hidden/CustomWallet'; // import ClaimFromDashboard from './pages/claim-from-dashboard/ClaimFromDashboard'; @@ -178,6 +187,10 @@ export const Routes: React.FC = () => { path="/app-store/developer/edit/:listingId" component={SubmissionForm} /> + + + + diff --git a/apps/learn-card-app/src/components/credentials/OBv3CredentialBuilder.tsx b/apps/learn-card-app/src/components/credentials/OBv3CredentialBuilder.tsx index 3cb3fc522d..9972fb75bf 100644 --- a/apps/learn-card-app/src/components/credentials/OBv3CredentialBuilder.tsx +++ b/apps/learn-card-app/src/components/credentials/OBv3CredentialBuilder.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { X, Copy, @@ -15,11 +15,76 @@ import { HelpCircle, Upload, Loader2, + Save, + FolderOpen, + Trash2, + Clock, + Plus, + ChevronLeft, + CheckCircle2, + AlertCircle, + ShieldCheck, } from 'lucide-react'; import { useFilestack, BoostCategoryOptionsEnum, BoostPageViewMode, useWallet } from 'learn-card-base'; import { BoostEarnedCard } from '../boost/boost-earned-card/BoostEarnedCard'; +// Storage key for saved credentials +const STORAGE_KEY = 'lc-credential-builder-saved'; +const DRAFT_KEY = 'lc-credential-builder-draft'; + +// Saved credential type +interface SavedCredential { + id: string; + name: string; + data: CredentialData; + createdAt: number; + updatedAt: number; +} + +// Storage helpers +const getSavedCredentials = (): SavedCredential[] => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +}; + +const saveCredentials = (credentials: SavedCredential[]) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(credentials)); +}; + +const getDraft = (): { data: CredentialData; savedAt: number } | null => { + try { + const stored = localStorage.getItem(DRAFT_KEY); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } +}; + +const saveDraft = (data: CredentialData) => { + localStorage.setItem(DRAFT_KEY, JSON.stringify({ data, savedAt: Date.now() })); +}; + +const clearDraft = () => { + localStorage.removeItem(DRAFT_KEY); +}; + +// Format relative time +const formatRelativeTime = (timestamp: number): string => { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + + return new Date(timestamp).toLocaleDateString(); +}; + // Valid OBv3 Achievement Types const ACHIEVEMENT_TYPES = [ { value: 'Achievement', label: 'Achievement', description: 'Generic achievement or accomplishment' }, @@ -149,10 +214,55 @@ export const OBv3CredentialBuilder: React.FC = ({ const [copied, setCopied] = useState(false); const [userDid, setUserDid] = useState('did:web:preview.learncard.com'); + // Saved credentials state + const [savedCredentials, setSavedCredentials] = useState([]); + const [currentCredentialId, setCurrentCredentialId] = useState(null); + const [showLibrary, setShowLibrary] = useState(false); + const [draftSavedAt, setDraftSavedAt] = useState(null); + const [showSaveAs, setShowSaveAs] = useState(false); + const [saveAsName, setSaveAsName] = useState(''); + + // Verification state + const [verificationStatus, setVerificationStatus] = useState<'idle' | 'verifying' | 'valid' | 'invalid'>('idle'); + const [verificationError, setVerificationError] = useState(null); + + // Load saved credentials and draft on mount + useEffect(() => { + if (isOpen) { + const saved = getSavedCredentials(); + setSavedCredentials(saved); + + // Load draft if no initialData and no current credential + if (!initialData && !currentCredentialId) { + const draft = getDraft(); + + if (draft) { + setData(draft.data); + setDraftSavedAt(draft.savedAt); + } + } + } + }, [isOpen, initialData, currentCredentialId]); + + // Auto-save draft with debounce + useEffect(() => { + if (!isOpen) return; + + const timeoutId = setTimeout(() => { + // Only save draft if there's meaningful content + if (data.achievementName || data.achievementDescription) { + saveDraft(data); + setDraftSavedAt(Date.now()); + } + }, 500); + + return () => clearTimeout(timeoutId); + }, [data, isOpen]); + // Get user's wallet for DID const { initWallet } = useWallet(); - React.useEffect(() => { + useEffect(() => { const fetchDid = async () => { try { const wallet = await initWallet(); @@ -178,35 +288,133 @@ export const OBv3CredentialBuilder: React.FC = ({ setData((prev) => ({ ...prev, [field]: value })); }; - // Build the OBv3 credential object + // Save credential to library + const handleSaveToLibrary = useCallback((name?: string) => { + const credName = name || data.achievementName || 'Untitled Credential'; + const now = Date.now(); + + if (currentCredentialId) { + // Update existing + const updated = savedCredentials.map(c => + c.id === currentCredentialId + ? { ...c, name: credName, data, updatedAt: now } + : c + ); + setSavedCredentials(updated); + saveCredentials(updated); + } else { + // Create new + const newCred: SavedCredential = { + id: crypto.randomUUID(), + name: credName, + data, + createdAt: now, + updatedAt: now, + }; + const updated = [newCred, ...savedCredentials]; + setSavedCredentials(updated); + saveCredentials(updated); + setCurrentCredentialId(newCred.id); + } + + clearDraft(); + setShowSaveAs(false); + setSaveAsName(''); + }, [data, currentCredentialId, savedCredentials]); + + // Load a saved credential + const handleLoadCredential = useCallback((cred: SavedCredential) => { + setData(cred.data); + setCurrentCredentialId(cred.id); + setShowLibrary(false); + clearDraft(); + }, []); + + // Delete a saved credential + const handleDeleteCredential = useCallback((id: string) => { + const updated = savedCredentials.filter(c => c.id !== id); + setSavedCredentials(updated); + saveCredentials(updated); + + if (currentCredentialId === id) { + setCurrentCredentialId(null); + } + }, [savedCredentials, currentCredentialId]); + + // Start new credential (auto-save current if it has content) + const handleNewCredential = useCallback(() => { + // Auto-save current credential if it has meaningful content and isn't already saved + const hasContent = data.achievementName || data.achievementDescription; + + if (hasContent && !currentCredentialId) { + // Save the current work as a new credential + const credName = data.achievementName || 'Untitled Credential'; + const now = Date.now(); + const newCred: SavedCredential = { + id: crypto.randomUUID(), + name: credName, + data, + createdAt: now, + updatedAt: now, + }; + const updated = [newCred, ...savedCredentials]; + setSavedCredentials(updated); + saveCredentials(updated); + } else if (hasContent && currentCredentialId) { + // Update the existing credential before starting new + const updated = savedCredentials.map(c => + c.id === currentCredentialId + ? { ...c, data, updatedAt: Date.now() } + : c + ); + setSavedCredentials(updated); + saveCredentials(updated); + } + + // Now start fresh + setData({ ...DEFAULT_DATA }); + setCurrentCredentialId(null); + setShowLibrary(false); + clearDraft(); + }, [data, currentCredentialId, savedCredentials]); + + // Build the OBv3 credential object (proper spec-compliant structure) const credential = useMemo(() => { + const credentialId = `urn:uuid:${crypto.randomUUID()}`; + const achievementId = `urn:uuid:${crypto.randomUUID()}`; + const issuerDid = userDid || 'did:example:issuer'; + const cred: Record = { '@context': [ 'https://www.w3.org/2018/credentials/v1', - 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json', + 'https://purl.imsglobal.org/spec/ob/v3p0/context.json', ], + id: credentialId, type: ['VerifiableCredential', 'OpenBadgeCredential'], name: data.credentialName || data.achievementName || 'Untitled Credential', + issuer: issuerDid, + issuanceDate: new Date().toISOString(), credentialSubject: { + id: issuerDid, // Will be replaced with recipient DID when issued + type: ['AchievementSubject'], achievement: { + id: achievementId, type: ['Achievement'], name: data.achievementName || 'Untitled', description: data.achievementDescription || '', achievementType: data.achievementType, + criteria: data.criteriaText || data.criteriaUrl + ? { + ...(data.criteriaText && { narrative: data.criteriaText }), + ...(data.criteriaUrl && { id: data.criteriaUrl }), + } + : { narrative: 'Criteria for earning this credential.' }, ...(data.achievementImage && { image: { id: data.achievementImage, type: 'Image', }, }), - ...(data.criteriaText || data.criteriaUrl - ? { - criteria: { - ...(data.criteriaText && { narrative: data.criteriaText }), - ...(data.criteriaUrl && { id: data.criteriaUrl }), - }, - } - : {}), }, }, }; @@ -232,7 +440,7 @@ export const OBv3CredentialBuilder: React.FC = ({ } return cred; - }, [data]); + }, [data, userDid]); const credentialJson = useMemo(() => JSON.stringify(credential, null, 2), [credential]); @@ -242,6 +450,32 @@ export const OBv3CredentialBuilder: React.FC = ({ setTimeout(() => setCopied(false), 2000); }; + // Verify credential by attempting to issue it + const handleVerify = useCallback(async () => { + setVerificationStatus('verifying'); + setVerificationError(null); + + try { + const wallet = await initWallet(); + + // Try to issue the credential - this will validate the JSON-LD structure + await wallet.invoke.issueCredential(credential as Parameters[0]); + + setVerificationStatus('valid'); + } catch (err) { + setVerificationStatus('invalid'); + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + setVerificationError(errorMessage); + console.error('Credential verification failed:', err); + } + }, [credential, initWallet]); + + // Reset verification when data changes + useEffect(() => { + setVerificationStatus('idle'); + setVerificationError(null); + }, [data]); + const handleSave = () => { onSave?.(credential); onClose(); @@ -251,31 +485,162 @@ export const OBv3CredentialBuilder: React.FC = ({ if (!isOpen) return null; + // Get current credential name for display + const currentCredName = savedCredentials.find(c => c.id === currentCredentialId)?.name; + return (
{/* Header */}
-
- -
+ {showLibrary ? ( + + ) : ( +
+ +
+ )}
-

Credential Builder

-

Create an Open Badges 3.0 credential

+

+ {showLibrary ? 'Saved Credentials' : 'Credential Builder'} +

+ +
+ {showLibrary ? ( + {savedCredentials.length} saved credential{savedCredentials.length !== 1 ? 's' : ''} + ) : ( + <> + {currentCredName ? ( + {currentCredName} + ) : ( + New credential + )} + + {draftSavedAt && !currentCredentialId && ( + + + Draft saved {formatRelativeTime(draftSavedAt)} + + )} + + )} +
- +
+ {!showLibrary && ( + + )} + + +
- {/* Tabs */} + {/* Library Panel */} + {showLibrary ? ( +
+
+ {/* New Credential Button */} + + + {/* Saved Credentials List */} + {savedCredentials.length > 0 ? ( +
+ {savedCredentials.map((cred) => ( +
+
+
+ {cred.data.achievementImage ? ( + + ) : ( + + )} +
+ +
+

{cred.name}

+ +

+ {cred.data.achievementType} • Updated {formatRelativeTime(cred.updatedAt)} +

+
+ +
+ + + +
+
+
+ ))} +
+ ) : ( +
+ + +

No saved credentials yet

+ +

+ Save your credentials to quickly reuse them later +

+
+ )} +
+
+ ) : ( + <> + {/* Tabs */}
+
+ + {verificationStatus === 'valid' && ( +
+ +
+

Valid OBv3 Credential

+

This credential structure passed JSON-LD expansion and can be issued.

+
+
+ )} + + {verificationStatus === 'invalid' && ( +
+
+ +

Invalid Credential Structure

+
+

+ {verificationError} +

+
+ )}
)} @@ -648,17 +1060,69 @@ export const OBv3CredentialBuilder: React.FC = ({ {/* Footer */}
- + + {/* Save to Library */} + {showSaveAs ? ( +
+ setSaveAsName(e.target.value)} + placeholder={data.achievementName || 'Credential name'} + className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveToLibrary(saveAsName || undefined); + if (e.key === 'Escape') { + setShowSaveAs(false); + setSaveAsName(''); + } + }} + /> + + + + +
) : ( - + )} - Copy JSON - +
+ + )} ); diff --git a/apps/learn-card-app/src/index.scss b/apps/learn-card-app/src/index.scss index a5c22d405c..cac6f5066f 100644 --- a/apps/learn-card-app/src/index.scss +++ b/apps/learn-card-app/src/index.scss @@ -83,3 +83,24 @@ body.scanner-active > * { scrollbar-color: #888888 #f1f1f1; } } + +/* Fix browser autofill dark background - force light scheme on inputs with gray borders (guide pages) */ +input[class*="border-gray"], +textarea[class*="border-gray"] { + color-scheme: light !important; + background-color: white !important; +} + +input[class*="border-gray"]:-webkit-autofill, +input[class*="border-gray"]:-webkit-autofill:hover, +input[class*="border-gray"]:-webkit-autofill:focus, +input[class*="border-gray"]:-webkit-autofill:active, +textarea[class*="border-gray"]:-webkit-autofill, +textarea[class*="border-gray"]:-webkit-autofill:hover, +textarea[class*="border-gray"]:-webkit-autofill:focus, +textarea[class*="border-gray"]:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 30px white inset !important; + -webkit-text-fill-color: #374151 !important; + caret-color: #374151 !important; + transition: background-color 5000s ease-in-out 0s; +} diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/components/AppStoreHeader.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/components/AppStoreHeader.tsx index 07f213be77..9a46326e80 100644 --- a/apps/learn-card-app/src/pages/appStoreDeveloper/components/AppStoreHeader.tsx +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/components/AppStoreHeader.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { IonHeader, IonToolbar } from '@ionic/react'; -import { Shield, Code2 } from 'lucide-react'; +import { Shield, Code2, Hammer } from 'lucide-react'; import QRCodeScannerButton from '../../../components/qrcode-scanner-button/QRCodeScannerButton'; import { BrandingEnum } from 'learn-card-base/components/headerBranding/headerBrandingHelpers'; @@ -19,6 +19,10 @@ export const AppStoreHeader: React.FC = ({ title = 'App Sto const { data: isAdmin } = useIsAdmin(); const isOnAdminPage = location.pathname.includes('/app-store/admin'); + const isOnGuidesPage = location.pathname.includes('/app-store/developer/guides'); + const isOnDeveloperPage = location.pathname === '/app-store/developer' || + location.pathname.startsWith('/app-store/developer/new') || + location.pathname.startsWith('/app-store/developer/edit'); const handlePortalToggle = () => { if (isOnAdminPage) { @@ -48,6 +52,45 @@ export const AppStoreHeader: React.FC = ({ title = 'App Sto
{rightContent} + {/* Navigation tabs */} +
+ + + +
+ + {/* Mobile nav toggle for guides */} + + {isAdmin && ( +
+ ); +}; + +export default CodeBlock; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/components/IntegrationGuidePanel.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/components/IntegrationGuidePanel.tsx index 57828c5b25..5ba08e2b37 100644 --- a/apps/learn-card-app/src/pages/appStoreDeveloper/components/IntegrationGuidePanel.tsx +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/components/IntegrationGuidePanel.tsx @@ -3,6 +3,7 @@ import { X, Copy, Check, ExternalLink, ChevronRight, Code, Globe, Package, Zap, import { OBv3CredentialBuilder } from '../../../components/credentials/OBv3CredentialBuilder'; import { Clipboard } from '@capacitor/clipboard'; +import { CodeBlock } from './CodeBlock'; import { useWallet, useToast, ToastTypeEnum, useConfirmation } from 'learn-card-base'; import type { AppPermission } from '../types'; @@ -27,94 +28,7 @@ interface IntegrationGuidePanelProps { webhookUrl?: string; } -// Simple syntax highlighter for TypeScript/JavaScript -const highlightCode = (code: string): React.ReactNode[] => { - const tokens: { type: string; value: string }[] = []; - let remaining = code; - - const patterns: { type: string; regex: RegExp }[] = [ - { type: 'comment', regex: /^(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/ }, - { type: 'string', regex: /^(`[\s\S]*?`|'[^']*'|"[^"]*")/ }, - { type: 'keyword', regex: /^(const|let|var|function|async|await|return|import|export|from|if|else|for|while|class|new|typeof|instanceof)\b/ }, - { type: 'boolean', regex: /^(true|false|null|undefined)\b/ }, - { type: 'function', regex: /^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/ }, - { type: 'property', regex: /^(\.[a-zA-Z_$][a-zA-Z0-9_$]*)/ }, - { type: 'number', regex: /^(\d+\.?\d*)/ }, - { type: 'punctuation', regex: /^([{}[\]();:,])/ }, - { type: 'operator', regex: /^(=>|===|!==|==|!=|<=|>=|&&|\|\||[+\-*/%=<>!&|])/ }, - { type: 'text', regex: /^[^\s]+/ }, - { type: 'whitespace', regex: /^(\s+)/ }, - ]; - - while (remaining.length > 0) { - let matched = false; - - for (const { type, regex } of patterns) { - const match = remaining.match(regex); - - if (match) { - tokens.push({ type, value: match[0] }); - remaining = remaining.slice(match[0].length); - matched = true; - break; - } - } - - if (!matched) { - tokens.push({ type: 'text', value: remaining[0] }); - remaining = remaining.slice(1); - } - } - - const colorMap: Record = { - keyword: 'text-purple-400', - string: 'text-green-400', - comment: 'text-gray-500 italic', - function: 'text-yellow-300', - property: 'text-cyan-300', - number: 'text-orange-400', - boolean: 'text-orange-400', - punctuation: 'text-gray-400', - operator: 'text-pink-400', - text: 'text-gray-100', - whitespace: '', - }; - - return tokens.map((token, i) => ( - - {token.value} - - )); -}; - -const CodeBlock: React.FC<{ code: string; language?: string }> = ({ code, language = 'typescript' }) => { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - await navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
-
-                {highlightCode(code)}
-            
- - -
- ); -}; +// CodeBlock imported from ./CodeBlock const StepCard: React.FC<{ step: number; @@ -759,12 +673,15 @@ const userDID = await getUserLearnCardDID(userId); // Send a credential to the user await learnCard.invoke.send({ + type: 'boost', recipient: userDID, contractUri: consentFlowContractURI, template: { - credential: ${credJson} + credential: ${credJson}, + name: 'Course Completion', + category: 'Achievement', } -});`; +})`; } return `// Get the user's DID (stored from Step 2) @@ -772,6 +689,7 @@ const userDID = await getUserLearnCardDID(userId); // Send a credential to the user await learnCard.invoke.send({ + type: 'boost', recipient: userDID, contractUri: consentFlowContractURI, template: { @@ -791,9 +709,11 @@ await learnCard.invoke.send({ image: 'https://placehold.co/400x400?text=Badge' } } - } + }, + name: 'Course Completion', + category: 'Achievement', } -});`; +})`; }, [builtCredential]); return ( @@ -1346,6 +1266,7 @@ initTutorSession({ diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/GuidePage.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/GuidePage.tsx new file mode 100644 index 0000000000..987a67509b --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/GuidePage.tsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useHistory, useLocation } from 'react-router-dom'; +import { IonPage, IonContent } from '@ionic/react'; +import { ArrowLeft, RotateCcw } from 'lucide-react'; +import type { LCNIntegration } from '@learncard/types'; + +import { AppStoreHeader } from '../components/AppStoreHeader'; +import { HeaderIntegrationSelector } from '../components/HeaderIntegrationSelector'; +import { useDeveloperPortal } from '../useDeveloperPortal'; +import { USE_CASES, UseCaseId } from './types'; + +import IssueCredentialsGuide from './useCases/IssueCredentialsGuide'; +import EmbedClaimGuide from './useCases/EmbedClaimGuide'; +import EmbedAppGuide from './useCases/EmbedAppGuide'; +import ConsentFlowGuide from './useCases/ConsentFlowGuide'; +import VerifyCredentialsGuide from './useCases/VerifyCredentialsGuide'; +import ServerWebhooksGuide from './useCases/ServerWebhooksGuide'; + +// Guide components that accept integration prop +export interface GuideProps { + selectedIntegration: LCNIntegration | null; + setSelectedIntegration: (integration: LCNIntegration | null) => void; +} + +const GUIDE_COMPONENTS: Record> = { + 'issue-credentials': IssueCredentialsGuide, + 'embed-claim': EmbedClaimGuide, + 'embed-app': EmbedAppGuide, + 'consent-flow': ConsentFlowGuide, + 'verify-credentials': VerifyCredentialsGuide, + 'server-webhooks': ServerWebhooksGuide, +}; + +const GuidePage: React.FC = () => { + const { useCase } = useParams<{ useCase: string }>(); + const history = useHistory(); + const location = useLocation(); + + // Get integrationId from URL query params + const searchParams = new URLSearchParams(location.search); + const urlIntegrationId = searchParams.get('integrationId'); + + const [selectedIntegrationId, setSelectedIntegrationId] = useState(urlIntegrationId); + const [selectedIntegration, setSelectedIntegration] = useState(null); + + const { useIntegrations } = useDeveloperPortal(); + const { data: integrations, isLoading: isLoadingIntegrations } = useIntegrations(); + + // Set integration from URL param or default to first + useEffect(() => { + if (integrations && integrations.length > 0) { + if (urlIntegrationId) { + const found = integrations.find(i => i.id === urlIntegrationId); + if (found) { + setSelectedIntegration(found); + setSelectedIntegrationId(found.id); + return; + } + } + // Default to first if URL param not found + if (!selectedIntegrationId) { + setSelectedIntegration(integrations[0]); + setSelectedIntegrationId(integrations[0].id); + } + } + }, [integrations, urlIntegrationId, selectedIntegrationId]); + + // Update selected integration when ID changes + useEffect(() => { + if (integrations && selectedIntegrationId) { + const found = integrations.find(i => i.id === selectedIntegrationId); + if (found) { + setSelectedIntegration(found); + // Update URL without navigation + const newSearchParams = new URLSearchParams(location.search); + newSearchParams.set('integrationId', found.id); + history.replace({ search: newSearchParams.toString() }); + } + } + }, [selectedIntegrationId, integrations]); + + const handleSetSelectedIntegration = (integration: LCNIntegration | null) => { + setSelectedIntegration(integration); + setSelectedIntegrationId(integration?.id || null); + }; + + const useCaseId = useCase as UseCaseId; + const useCaseConfig = USE_CASES[useCaseId]; + const GuideComponent = GUIDE_COMPONENTS[useCaseId]; + + if (!useCaseConfig || !GuideComponent) { + return ( + + + + +
+

Guide Not Found

+ +

+ The guide you're looking for doesn't exist. +

+ + +
+
+
+ ); + } + + const headerContent = ( +
+ + + +
+ ); + + return ( + + + + + + + + ); +}; + +export default GuidePage; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/IntegrationHub.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/IntegrationHub.tsx new file mode 100644 index 0000000000..99d2972632 --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/IntegrationHub.tsx @@ -0,0 +1,344 @@ +import React, { useState, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { IonPage, IonContent } from '@ionic/react'; +import { + Award, + Layout, + ShieldCheck, + CheckCircle, + Webhook, + MousePointerClick, + ArrowRight, + Sparkles, + BookOpen, + ExternalLink, + Rocket, + Loader2, +} from 'lucide-react'; + +import { AppStoreHeader } from '../components/AppStoreHeader'; +import { HeaderIntegrationSelector } from '../components/HeaderIntegrationSelector'; +import { useDeveloperPortal } from '../useDeveloperPortal'; +import { USE_CASES, UseCaseId } from './types'; + +const ICON_MAP: Record> = { + 'award': Award, + 'mouse-pointer-click': MousePointerClick, + 'layout': Layout, + 'shield-check': ShieldCheck, + 'check-circle': CheckCircle, + 'webhook': Webhook, +}; + +interface UseCaseCardProps { + id: UseCaseId; + title: string; + subtitle: string; + description: string; + icon: string; + color: string; + bgColor: string; + comingSoon?: boolean; + onClick: () => void; +} + +const UseCaseCard: React.FC = ({ + title, + subtitle, + description, + icon, + color, + bgColor, + comingSoon, + onClick, +}) => { + const IconComponent = ICON_MAP[icon] || Award; + + if (comingSoon) { + return ( +
+
+
+ +
+ + + Coming Soon + +
+ +

{title}

+ +

{subtitle}

+ +

{description}

+
+ ); + } + + return ( + + ); +}; + +const IntegrationHub: React.FC = () => { + const history = useHistory(); + const [selectedIntegrationId, setSelectedIntegrationId] = useState(null); + const [newProjectName, setNewProjectName] = useState(''); + + const { useIntegrations, useCreateIntegration } = useDeveloperPortal(); + const { data: integrations, isLoading: isLoadingIntegrations } = useIntegrations(); + const createIntegrationMutation = useCreateIntegration(); + + // Default to first integration when loaded + useEffect(() => { + if (!selectedIntegrationId && integrations && integrations.length > 0) { + setSelectedIntegrationId(integrations[0].id); + } + }, [integrations, selectedIntegrationId]); + + const handleUseCaseClick = (useCaseId: UseCaseId) => { + if (selectedIntegrationId) { + history.push(`/app-store/developer/guides/${useCaseId}?integrationId=${selectedIntegrationId}`); + } + }; + + const handleCreateFirstProject = async () => { + if (!newProjectName.trim()) return; + + try { + const integrationId = await createIntegrationMutation.mutateAsync(newProjectName.trim()); + setSelectedIntegrationId(integrationId); + setNewProjectName(''); + } catch (error) { + console.error('Failed to create project:', error); + } + }; + + const useCaseList = Object.values(USE_CASES); + const hasIntegration = selectedIntegrationId !== null; + const showSetupPrompt = !isLoadingIntegrations && integrations?.length === 0; + + const integrationSelector = ( + + ); + + return ( + + + + +
+ {/* Setup prompt when no integrations exist */} + {showSetupPrompt && ( +
+
+
+ +
+ +

+ Create Your First Project +

+ +

+ Set up a project to start building your integration. +

+
+ +
+ + +
+ setNewProjectName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleCreateFirstProject()} + placeholder="e.g. My Awesome App" + className="flex-1 px-4 py-3 bg-white border border-gray-200 rounded-xl text-base text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500 shadow-sm" + disabled={createIntegrationMutation.isPending} + /> + + +
+
+
+ )} + + {/* Main content when integration is selected */} + {hasIntegration && ( + <> + {/* Hero section */} +
+
+ + Integration Guides +
+ +

+ Build Your Integration +

+ +

+ Choose what you want to build. We'll guide you through each step with + ready-to-use code and live setup tools. +

+
+ + {/* Use case grid */} +
+ {useCaseList.map(useCase => ( + handleUseCaseClick(useCase.id)} + /> + ))} +
+ + {/* Enterprise Partner Onboarding */} +
+ +
+ + )} + + {/* Resources section - always show when not in setup */} + {!showSetupPrompt && ( +
+

+ + Additional Resources +

+ +
+ +
+ +
+ +
+

Documentation

+

Full API reference

+
+ + +
+ + +
+ + + +
+ +
+

GitHub

+

Open source SDKs

+
+ + +
+ + +
+
+ )} +
+
+
+ ); +}; + +export default IntegrationHub; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/index.ts b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/index.ts new file mode 100644 index 0000000000..e1512f606e --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/index.ts @@ -0,0 +1,4 @@ +export { default as IntegrationHub } from './IntegrationHub'; +export { default as GuidePage } from './GuidePage'; +export * from './types'; +export * from './shared'; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/CodeOutputPanel.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/CodeOutputPanel.tsx new file mode 100644 index 0000000000..76b5ce949c --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/CodeOutputPanel.tsx @@ -0,0 +1,187 @@ +import React, { useState, useMemo } from 'react'; +import { Copy, Check, ChevronDown, ChevronUp } from 'lucide-react'; + +type Language = 'typescript' | 'python' | 'curl'; + +interface CodeOutputPanelProps { + snippets: { + typescript?: string; + python?: string; + curl?: string; + }; + title?: string; + defaultLanguage?: Language; + collapsible?: boolean; + defaultExpanded?: boolean; +} + +// Simple syntax highlighter +const highlightCode = (code: string): React.ReactNode[] => { + const tokens: { type: string; value: string }[] = []; + let remaining = code; + + const patterns: { type: string; regex: RegExp }[] = [ + { type: 'comment', regex: /^(\/\/[^\n]*|\/\*[\s\S]*?\*\/|#[^\n]*)/ }, + { type: 'string', regex: /^(`[\s\S]*?`|'[^']*'|"[^"]*")/ }, + { type: 'keyword', regex: /^(const|let|var|function|async|await|return|import|export|from|if|else|for|while|class|new|typeof|instanceof|def|import|from|as|try|except|with|async|await)\b/ }, + { type: 'boolean', regex: /^(true|false|null|undefined|None|True|False)\b/ }, + { type: 'function', regex: /^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/ }, + { type: 'property', regex: /^(\.[a-zA-Z_$][a-zA-Z0-9_$]*)/ }, + { type: 'number', regex: /^(\d+\.?\d*)/ }, + { type: 'punctuation', regex: /^([{}[\]();:,])/ }, + { type: 'operator', regex: /^(=>|===|!==|==|!=|<=|>=|&&|\|\||[+\-*/%=<>!&|])/ }, + { type: 'flag', regex: /^(-{1,2}[a-zA-Z][a-zA-Z0-9-]*)/ }, + { type: 'text', regex: /^[^\s]+/ }, + { type: 'whitespace', regex: /^(\s+)/ }, + ]; + + while (remaining.length > 0) { + let matched = false; + + for (const { type, regex } of patterns) { + const match = remaining.match(regex); + + if (match) { + tokens.push({ type, value: match[0] }); + remaining = remaining.slice(match[0].length); + matched = true; + break; + } + } + + if (!matched) { + tokens.push({ type: 'text', value: remaining[0] }); + remaining = remaining.slice(1); + } + } + + const colorMap: Record = { + keyword: 'text-purple-400', + string: 'text-green-400', + comment: 'text-gray-500 italic', + function: 'text-yellow-300', + property: 'text-cyan-300', + number: 'text-orange-400', + boolean: 'text-orange-400', + punctuation: 'text-gray-400', + operator: 'text-pink-400', + flag: 'text-cyan-400', + text: 'text-gray-100', + whitespace: '', + }; + + return tokens.map((token, i) => ( + + {token.value} + + )); +}; + +const LANGUAGE_LABELS: Record = { + typescript: 'TypeScript', + python: 'Python', + curl: 'cURL', +}; + +export const CodeOutputPanel: React.FC = ({ + snippets, + title = 'Code', + defaultLanguage = 'typescript', + collapsible = false, + defaultExpanded = true, +}) => { + const availableLanguages = useMemo(() => { + return (Object.keys(snippets) as Language[]).filter(lang => snippets[lang]); + }, [snippets]); + + const [selectedLanguage, setSelectedLanguage] = useState( + availableLanguages.includes(defaultLanguage) ? defaultLanguage : availableLanguages[0] + ); + + const [copied, setCopied] = useState(false); + const [expanded, setExpanded] = useState(defaultExpanded); + + const currentCode = snippets[selectedLanguage] || ''; + + const handleCopy = async () => { + await navigator.clipboard.writeText(currentCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (availableLanguages.length === 0) return null; + + return ( +
+ {/* Header */} +
+
+ {collapsible && ( + + )} + + {title} +
+ +
+ {/* Language selector */} + {availableLanguages.length > 1 && ( +
+ {availableLanguages.map(lang => ( + + ))} +
+ )} + + {/* Copy button */} + +
+
+ + {/* Code content */} + {expanded && ( +
+                    {highlightCode(currentCode)}
+                
+ )} +
+ ); +}; + +export default CodeOutputPanel; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/StatusIndicator.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/StatusIndicator.tsx new file mode 100644 index 0000000000..ed3135aea6 --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/StatusIndicator.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Check, AlertCircle, Loader2 } from 'lucide-react'; + +export type StatusType = 'ready' | 'warning' | 'loading' | 'incomplete'; + +interface StatusIndicatorProps { + status: StatusType; + label: string; + description?: string; + action?: { + label: string; + onClick: () => void; + }; +} + +const STATUS_STYLES: Record = { + ready: { + bg: 'bg-emerald-50 border-emerald-200', + text: 'text-emerald-700', + icon: , + }, + warning: { + bg: 'bg-amber-50 border-amber-200', + text: 'text-amber-700', + icon: , + }, + loading: { + bg: 'bg-gray-50 border-gray-200', + text: 'text-gray-600', + icon: , + }, + incomplete: { + bg: 'bg-gray-50 border-gray-200', + text: 'text-gray-500', + icon:
, + }, +}; + +export const StatusIndicator: React.FC = ({ + status, + label, + description, + action, +}) => { + const styles = STATUS_STYLES[status]; + + return ( +
+
+ {styles.icon} +
+ +
+

{label}

+ + {description && ( +

{description}

+ )} +
+ + {action && ( + + )} +
+ ); +}; + +export default StatusIndicator; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/StepProgress.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/StepProgress.tsx new file mode 100644 index 0000000000..9806e1bf07 --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/StepProgress.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Check } from 'lucide-react'; + +interface StepProgressProps { + currentStep: number; + totalSteps: number; + steps: { id: string; title: string }[]; + completedSteps: string[]; + onStepClick?: (index: number) => void; +} + +export const StepProgress: React.FC = ({ + currentStep, + totalSteps, + steps, + completedSteps, + onStepClick, +}) => { + return ( +
+ {/* Progress bar */} +
+ + Step {currentStep + 1} of {totalSteps} + + + + {completedSteps.length} completed + +
+ + {/* Step indicators */} +
+ {steps.map((step, index) => { + const isComplete = completedSteps.includes(step.id); + const isCurrent = index === currentStep; + const isPast = index < currentStep; + + return ( + + + + {index < steps.length - 1 && ( +
+ )} + + ); + })} +
+ + {/* Current step title */} +
+ + {steps[currentStep]?.title} + +
+
+ ); +}; + +export default StepProgress; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/index.ts b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/index.ts new file mode 100644 index 0000000000..40b1ddbaf4 --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/index.ts @@ -0,0 +1,6 @@ +export { StepProgress } from './StepProgress'; +export { CodeOutputPanel } from './CodeOutputPanel'; +export { StatusIndicator } from './StatusIndicator'; +export type { StatusType } from './StatusIndicator'; +export { useGuideState } from './useGuideState'; +export type { UseGuideStateReturn } from './useGuideState'; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/useGuideState.ts b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/useGuideState.ts new file mode 100644 index 0000000000..1f88303fb8 --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/shared/useGuideState.ts @@ -0,0 +1,157 @@ +import { useState, useCallback, useMemo } from 'react'; + +import type { GuideState, UseCaseId } from '../types'; + +const STORAGE_KEY_PREFIX = 'lc_guide_state_'; + +export interface UseGuideStateReturn { + state: GuideState; + currentStep: number; + totalSteps: number; + isStepComplete: (stepId: string) => boolean; + markStepComplete: (stepId: string) => void; + markStepIncomplete: (stepId: string) => void; + goToStep: (step: number) => void; + nextStep: () => void; + prevStep: () => void; + updateConfig: (key: string, value: unknown) => void; + getConfig: (key: string, defaultValue?: T) => T | undefined; + resetGuide: () => void; +} + +export function useGuideState(useCaseId: UseCaseId, totalSteps: number): UseGuideStateReturn { + const storageKey = `${STORAGE_KEY_PREFIX}${useCaseId}`; + + const loadState = (): GuideState => { + try { + const stored = localStorage.getItem(storageKey); + + if (stored) { + return JSON.parse(stored); + } + } catch (e) { + console.error('Failed to load guide state:', e); + } + + return { + currentStep: 0, + completedSteps: [], + config: {}, + }; + }; + + const [state, setState] = useState(loadState); + + const saveState = useCallback((newState: GuideState) => { + try { + localStorage.setItem(storageKey, JSON.stringify(newState)); + } catch (e) { + console.error('Failed to save guide state:', e); + } + }, [storageKey]); + + const isStepComplete = useCallback((stepId: string) => { + return state.completedSteps.includes(stepId); + }, [state.completedSteps]); + + const markStepComplete = useCallback((stepId: string) => { + setState(prev => { + if (prev.completedSteps.includes(stepId)) return prev; + + const newState = { + ...prev, + completedSteps: [...prev.completedSteps, stepId], + }; + + saveState(newState); + + return newState; + }); + }, [saveState]); + + const markStepIncomplete = useCallback((stepId: string) => { + setState(prev => { + const newState = { + ...prev, + completedSteps: prev.completedSteps.filter(id => id !== stepId), + }; + + saveState(newState); + + return newState; + }); + }, [saveState]); + + const goToStep = useCallback((step: number) => { + if (step < 0 || step >= totalSteps) return; + + setState(prev => { + const newState = { ...prev, currentStep: step }; + saveState(newState); + return newState; + }); + }, [totalSteps, saveState]); + + const nextStep = useCallback(() => { + goToStep(state.currentStep + 1); + }, [state.currentStep, goToStep]); + + const prevStep = useCallback(() => { + goToStep(state.currentStep - 1); + }, [state.currentStep, goToStep]); + + const updateConfig = useCallback((key: string, value: unknown) => { + setState(prev => { + const newState = { + ...prev, + config: { ...prev.config, [key]: value }, + }; + + saveState(newState); + + return newState; + }); + }, [saveState]); + + const getConfig = useCallback((key: string, defaultValue?: T): T | undefined => { + return (state.config[key] as T) ?? defaultValue; + }, [state.config]); + + const resetGuide = useCallback(() => { + const newState: GuideState = { + currentStep: 0, + completedSteps: [], + config: {}, + }; + + setState(newState); + saveState(newState); + }, [saveState]); + + return useMemo(() => ({ + state, + currentStep: state.currentStep, + totalSteps, + isStepComplete, + markStepComplete, + markStepIncomplete, + goToStep, + nextStep, + prevStep, + updateConfig, + getConfig, + resetGuide, + }), [ + state, + totalSteps, + isStepComplete, + markStepComplete, + markStepIncomplete, + goToStep, + nextStep, + prevStep, + updateConfig, + getConfig, + resetGuide, + ]); +} diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/types.ts b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/types.ts new file mode 100644 index 0000000000..1b6c34d03b --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/types.ts @@ -0,0 +1,91 @@ +export type UseCaseId = + | 'issue-credentials' + | 'embed-claim' + | 'embed-app' + | 'consent-flow' + | 'verify-credentials' + | 'server-webhooks'; + +export interface UseCaseConfig { + id: UseCaseId; + title: string; + subtitle: string; + description: string; + icon: string; + color: string; + bgColor: string; + steps: GuideStep[]; + comingSoon?: boolean; +} + +export interface GuideStep { + id: string; + title: string; + description: string; + component: string; +} + +export interface GuideState { + currentStep: number; + completedSteps: string[]; + config: Record; +} + +export const USE_CASES: Record> = { + 'issue-credentials': { + id: 'issue-credentials', + title: 'Issue Credentials', + subtitle: 'Give badges to users', + description: 'Issue verifiable credentials like badges, certificates, or achievements to your users.', + icon: 'award', + color: 'text-violet-600', + bgColor: 'bg-violet-100', + }, + 'embed-claim': { + id: 'embed-claim', + title: 'Embed Claim Button', + subtitle: 'Issue from your site', + description: 'Add a "Claim Credential" button to your website so users can claim badges without leaving your page.', + icon: 'mouse-pointer-click', + color: 'text-pink-600', + bgColor: 'bg-pink-100', + }, + 'embed-app': { + id: 'embed-app', + title: 'Embed Your App', + subtitle: 'Run inside LearnCard', + description: 'Build an app that runs inside the LearnCard wallet with access to user identity and credentials.', + icon: 'layout', + color: 'text-cyan-600', + bgColor: 'bg-cyan-100', + }, + 'consent-flow': { + id: 'consent-flow', + title: 'Connect Website', + subtitle: 'Connect your website to LearnCard', + description: 'Set up a consent flow to send and access user data and credentials with their permission.', + icon: 'shield-check', + color: 'text-emerald-600', + bgColor: 'bg-emerald-100', + }, + 'verify-credentials': { + id: 'verify-credentials', + title: 'Verify Credentials', + subtitle: 'Accept VCs from users', + description: 'Accept and verify credentials presented by users to prove their achievements or identity.', + icon: 'check-circle', + color: 'text-blue-600', + bgColor: 'bg-blue-100', + comingSoon: true, + }, + 'server-webhooks': { + id: 'server-webhooks', + title: 'Server Webhooks', + subtitle: 'Backend events', + description: 'Receive real-time notifications when events happen in LearnCard via webhooks.', + icon: 'webhook', + color: 'text-orange-600', + bgColor: 'bg-orange-100', + comingSoon: true, + }, +}; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/ConsentFlowGuide.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/ConsentFlowGuide.tsx new file mode 100644 index 0000000000..2ece5dfc8c --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/ConsentFlowGuide.tsx @@ -0,0 +1,751 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { + FileText, + Link2, + Webhook, + Rocket, + ArrowRight, + ArrowLeft, + ExternalLink, + CheckCircle2, + Globe, + Code, + Key, + Package, + Zap, + Award, + Check, + Database, + Copy, + Info, + ChevronDown, + ChevronUp, + Loader2, +} from 'lucide-react'; + +import { useWallet } from 'learn-card-base'; + +import { StepProgress } from '../shared'; +import { useGuideState } from '../shared/useGuideState'; + +import { ConsentFlowContractSelector } from '../../components/ConsentFlowContractSelector'; +import { CodeBlock } from '../../components/CodeBlock'; +import OBv3CredentialBuilder from '../../../../components/credentials/OBv3CredentialBuilder'; +import type { GuideProps } from '../GuidePage'; + +type AuthGrant = { + id: string; + name: string; + challenge: string; + createdAt: string; + status: 'revoked' | 'active'; + scope: string; + description?: string; +}; + +const STEPS = [ + { id: 'create-contract', title: 'Create Contract' }, + { id: 'redirect-handler', title: 'Redirect Handler' }, + { id: 'api-setup', title: 'API Setup' }, + { id: 'send-credentials', title: 'Send Credentials' }, +]; + +// Step Card component for consistent styling +const StepCard: React.FC<{ + step: number; + title: string; + icon: React.ReactNode; + children: React.ReactNode; +}> = ({ step, title, icon, children }) => ( +
+
+
+ {step} +
+ +

{title}

+ + {icon} +
+ +
{children}
+
+); + +// Step 1: Create Contract +const CreateContractStep: React.FC<{ + onComplete: () => void; + contractUri: string; + setContractUri: (uri: string) => void; +}> = ({ onComplete, contractUri, setContractUri }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (contractUri) { + await navigator.clipboard.writeText(contractUri); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( +
+
+

Create a Consent Flow Contract

+ +

+ A consent contract defines what data you're requesting and why. Users must accept + the contract before sharing their data. +

+
+ +
+

+ Consent Redirect Flow: Collect user consent and credentials from your external application. + Users will be redirected to LearnCard to grant permissions, then back to your app with their credentials. +

+
+ + + + {contractUri && ( +
+
+
+ + +
+

Contract Selected

+ +

{contractUri}

+
+
+ + +
+ +
+

+ Important: Save this Contract URI — you'll need it to send credentials later. +

+
+
+ )} + + + + +
+ ); +}; + +// Step 2: Redirect Handler Setup +const RedirectHandlerStep: React.FC<{ + onComplete: () => void; + onBack: () => void; + contractUri: string; + redirectUrl: string; + setRedirectUrl: (url: string) => void; +}> = ({ onComplete, onBack, contractUri, redirectUrl, setRedirectUrl }) => { + return ( +
+
+

Set Up Your Redirect Handler

+ +

+ When users click "Connect with LearnCard" in your app, they'll be redirected to LearnCard to grant consent, + then back to your app with their DID and credentials. +

+
+ +
+ + + setRedirectUrl(e.target.value)} + placeholder="https://your-app.com/api/learncard/callback" + className="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-500" + /> + +

+ Users will be redirected here after granting consent +

+
+ + }> +

+ Create a button that redirects users to the consent flow: +

+ + +
+ + }> +

+ Create an endpoint to handle the redirect. The user's DID and Delegate VP JWT + will be included in the URL parameters. +

+ + { + // Extract the user's DID and Delegate VP from URL params + const { did, delegateVpJwt } = req.query; + + // Store these with the user's account in your system + await saveUserLearnCardCredentials(userId, { + did: did, + delegateVpJwt: delegateVpJwt + }); + + // Redirect to your app's success page + res.redirect('/dashboard?connected=true'); +});`} + /> + +

+ Store the did and{' '} + delegateVpJwt to identify and + send credentials to this user later. +

+
+ +
+

Flow Summary

+ +
    +
  1. User clicks "Connect with LearnCard" in your app
  2. +
  3. User is redirected to LearnCard to grant consent
  4. +
  5. LearnCard redirects back to your app with their DID
  6. +
  7. Your backend stores the user's DID
  8. +
  9. When ready, your backend issues credentials to that DID
  10. +
+
+ +
+ + + +
+
+ ); +}; + +// Step 3: API Setup +const APISetupStep: React.FC<{ + onComplete: () => void; + onBack: () => void; + apiToken: string; + onTokenChange: (token: string) => void; +}> = ({ onComplete, onBack, apiToken, onTokenChange }) => { + const { initWallet } = useWallet(); + + // API Token selector state + const [authGrants, setAuthGrants] = useState[]>([]); + const [loadingGrants, setLoadingGrants] = useState(false); + const [selectedGrantId, setSelectedGrantId] = useState(null); + const [showTokenSelector, setShowTokenSelector] = useState(false); + + // Fetch auth grants on mount + useEffect(() => { + const fetchGrants = async () => { + setLoadingGrants(true); + try { + const wallet = await initWallet(); + const grants = await wallet.invoke.getAuthGrants() || []; + const activeGrants = grants.filter((g: Partial) => g.status === 'active'); + setAuthGrants(activeGrants); + } catch (err) { + console.error('Failed to fetch grants:', err); + } finally { + setLoadingGrants(false); + } + }; + fetchGrants(); + }, []); + + // Select a token + const selectToken = async (grantId: string) => { + try { + const wallet = await initWallet(); + const token = await wallet.invoke.getAPITokenForAuthGrant(grantId); + onTokenChange(token); + setSelectedGrantId(grantId); + setShowTokenSelector(false); + } catch (err) { + console.error('Failed to get token:', err); + } + }; + + // Get selected grant name for display + const selectedGrant = authGrants.find(g => g.id === selectedGrantId); + const displayTokenName = selectedGrant?.name || (apiToken ? 'Selected Token' : 'No token selected'); + + return ( +
+
+

Set Up Your Backend

+ +

+ Initialize the LearnCard SDK on your backend to send credentials and query consent data. +

+
+ + {/* API Token Selector */} +
+
+
+
+ +
+ +
+

API Token

+ +

+ {apiToken ? ( + + + {displayTokenName} + + ) : ( + Select a token to use + )} +

+
+
+ + +
+ + {showTokenSelector && ( +
+ {loadingGrants ? ( +
+ + Loading tokens... +
+ ) : authGrants.length === 0 ? ( +
+

No API tokens found.

+ +

+ Go to Admin Tools → API Keys to create one, then come back here. +

+
+ ) : ( +
+ {authGrants.map((grant) => ( + + ))} +
+ )} +
+ )} +
+ + }> +

+ Install the LearnCard SDK in your backend application: +

+ + +
+ + }> +

+ Initialize with your API token: +

+ + + +
+

+ Security: Store your API token in environment variables, never commit it to code. +

+
+
+ +
+ + + +
+
+ ); +}; + +// Step 4: Send Credentials +const SendCredentialsStep: React.FC<{ + onBack: () => void; + contractUri: string; + apiToken?: string; +}> = ({ onBack, contractUri, apiToken }) => { + const [showCredentialBuilder, setShowCredentialBuilder] = useState(false); + const [builtCredential, setBuiltCredential] = useState | null>(null); + + // Generate the code sample based on built credential or default + const credentialCodeSample = useMemo(() => { + if (builtCredential) { + const credJson = JSON.stringify(builtCredential, null, 12) + .split('\n') + .map((line, i) => (i === 0 ? line : ` ${line}`)) + .join('\n'); + + return `// Get the user's DID (stored from Step 2) +const userDID = await getUserLearnCardDID(userId); + +// Send a credential to the user +await learnCard.invoke.send({ + type: 'boost', + recipient: userDID, + contractUri: '${contractUri || 'YOUR_CONTRACT_URI'}', + template: { + credential: ${credJson}, + name: 'Course Completion', + category: 'Achievement', + } +});`; + } + + return `// Get the user's DID (stored from Step 2) +const userDID = await getUserLearnCardDID(userId); + +// Send a credential to the user +await learnCard.invoke.send({ + type: 'boost', + recipient: userDID, + contractUri: '${contractUri || 'YOUR_CONTRACT_URI'}', + template: { + credential: { + // Open Badges 3.0 credential + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json' + ], + type: ['VerifiableCredential', 'OpenBadgeCredential'], + name: 'Course Completion', + credentialSubject: { + achievement: { + name: 'Connected External App', + description: 'Awarded for connecting to our app.', + achievementType: 'Achievement', + image: 'https://placehold.co/400x400?text=Badge' + } + } + }, + name: 'Course Completion', + category: 'Achievement', + } +});`; + }, [builtCredential, contractUri]); + + return ( +
+ setShowCredentialBuilder(false)} + onSave={(cred) => setBuiltCredential(cred)} + /> + +
+

Send Credentials to Users

+ +

+ Now you can issue credentials to users who have connected with your app. +

+
+ + }> +

+ Use the credential builder to create your badge or use the code template below. +

+ + + + {builtCredential && ( +
+ + Custom credential added to code below +
+ )} +
+ + }> +

+ Use the simplified send method + to create, sign, and deliver credentials in one call. +

+ + + +
+

+ What this does: Creates a credential template, issues it to the user, + and writes it to your consent flow contract — all in one call. +

+
+
+ + }> +

+ As the contract owner, you can query consent data and transactions: +

+ +
+
+

Get all consented data for your contract:

+ + +
+ +
+

Get consent data for a specific user:

+ + +
+
+
+ +
+
+ +
+ +

Consent Flow Ready!

+ +

+ Users can now securely connect and receive credentials from your application. +

+
+ +
+
+ + +
+

Integration Tips

+ +
    +
  • • Store API keys in environment variables, never in code
  • +
  • • Test in sandbox mode before going live
  • +
  • • Store user DIDs securely with their account data
  • +
+
+
+
+ + +
+ ); +}; + +// Main component +const ConsentFlowGuide: React.FC = () => { + const guideState = useGuideState('consent-flow', STEPS.length); + + const [contractUri, setContractUri] = useState(''); + const [redirectUrl, setRedirectUrl] = useState(''); + const [apiToken, setApiToken] = useState(''); + + const handleStepComplete = (stepId: string) => { + guideState.markStepComplete(stepId); + guideState.nextStep(); + }; + + const renderStep = () => { + switch (guideState.currentStep) { + case 0: + return ( + handleStepComplete('create-contract')} + contractUri={contractUri} + setContractUri={setContractUri} + /> + ); + + case 1: + return ( + handleStepComplete('redirect-handler')} + onBack={guideState.prevStep} + contractUri={contractUri} + redirectUrl={redirectUrl} + setRedirectUrl={setRedirectUrl} + /> + ); + + case 2: + return ( + handleStepComplete('api-setup')} + onBack={guideState.prevStep} + apiToken={apiToken} + onTokenChange={setApiToken} + /> + ); + + case 3: + return ( + + ); + + default: + return null; + } + }; + + return ( +
+
+ +
+ + {renderStep()} +
+ ); +}; + +export default ConsentFlowGuide; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/EmbedAppGuide.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/EmbedAppGuide.tsx new file mode 100644 index 0000000000..86c9a61d5d --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/EmbedAppGuide.tsx @@ -0,0 +1,5666 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + Globe, + Package, + Code, + Rocket, + ArrowRight, + ArrowLeft, + ExternalLink, + CheckCircle2, + Plus, + FolderOpen, + Sparkles, + Terminal, + FileCode, + Layers, + Loader2, + XCircle, + AlertCircle, + Search, + Lock, + Shield, + Monitor, + User, + Send, + Navigation, + FileSearch, + Key, + ClipboardCheck, + FileText, + Copy, + Check, + ChevronRight, + Info, + Zap, + Award, + Trash2, + RefreshCw, + Eye, + MoreVertical, + Edit3, + Link as LinkIcon, + FileJson, + ChevronDown, + Server, + ShieldCheck, + Play, + Map, + Bot, + Layout, +} from 'lucide-react'; + +import type { LCNIntegration, AppStoreListing } from '@learncard/types'; + +import { StepProgress, CodeOutputPanel } from '../shared'; +import { useGuideState } from '../shared/useGuideState'; +import { useWallet, useToast, ToastTypeEnum, useModal, ModalTypes } from 'learn-card-base'; +import OBv3CredentialBuilder from '../../../../components/credentials/OBv3CredentialBuilder'; +import { useDeveloperPortal } from '../../useDeveloperPortal'; +import { ConsentFlowContractSelector } from '../../components/ConsentFlowContractSelector'; +import { CodeBlock } from '../../components/CodeBlock'; +import { PERMISSION_OPTIONS } from '../../types'; +import type { AppPermission, LaunchConfig, ExtendedAppStoreListing } from '../../types'; +import { AppPreviewModal } from '../../components/AppPreviewModal'; +import type { GuideProps } from '../GuidePage'; + +// URL Check types and helper +interface UrlCheckResult { + id: string; + label: string; + status: 'pending' | 'checking' | 'pass' | 'fail' | 'warn'; + message?: string; +} + +const checkUrl = async (url: string): Promise => { + const results: UrlCheckResult[] = [ + { id: 'https', label: 'HTTPS', status: 'pending' }, + { id: 'reachable', label: 'Reachable', status: 'pending' }, + { id: 'cors', label: 'CORS Headers', status: 'pending' }, + ]; + + // Check 1: HTTPS + try { + const parsed = new URL(url); + + if (parsed.protocol === 'https:') { + results[0] = { ...results[0], status: 'pass', message: 'Using secure HTTPS' }; + } else if (parsed.protocol === 'http:') { + if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') { + results[0] = { ...results[0], status: 'warn', message: 'HTTP allowed for localhost' }; + } else { + results[0] = { ...results[0], status: 'fail', message: 'HTTPS required for production' }; + } + } else { + results[0] = { ...results[0], status: 'fail', message: 'Invalid protocol' }; + } + } catch { + results[0] = { ...results[0], status: 'fail', message: 'Invalid URL format' }; + results[1] = { ...results[1], status: 'fail', message: 'Cannot check - invalid URL' }; + results[2] = { ...results[2], status: 'fail', message: 'Cannot check - invalid URL' }; + return results; + } + + // Check 2 & 3: Reachability and CORS + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(url, { + method: 'HEAD', + mode: 'cors', + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // Reachable + results[1] = { ...results[1], status: 'pass', message: `Status ${response.status}` }; + + // Check CORS headers + const corsHeader = response.headers.get('Access-Control-Allow-Origin'); + + if (corsHeader === '*' || corsHeader) { + results[2] = { ...results[2], status: 'pass', message: `CORS: ${corsHeader}` }; + } else { + results[2] = { ...results[2], status: 'warn', message: 'CORS header not visible (may still work)' }; + } + } catch (err) { + if (err instanceof TypeError && err.message.includes('Failed to fetch')) { + // CORS block or network error + results[1] = { ...results[1], status: 'warn', message: 'Blocked by CORS or unreachable' }; + results[2] = { ...results[2], status: 'fail', message: 'CORS not configured or blocking' }; + } else if (err instanceof DOMException && err.name === 'AbortError') { + results[1] = { ...results[1], status: 'fail', message: 'Request timed out (10s)' }; + results[2] = { ...results[2], status: 'pending', message: 'Could not check' }; + } else { + results[1] = { ...results[1], status: 'fail', message: 'Network error' }; + results[2] = { ...results[2], status: 'pending', message: 'Could not check' }; + } + } + + return results; +}; + +// URL Check Results Component +const UrlCheckResults: React.FC<{ results: UrlCheckResult[]; isChecking: boolean }> = ({ results, isChecking }) => { + const getIcon = (status: UrlCheckResult['status']) => { + switch (status) { + case 'checking': + return ; + case 'pass': + return ; + case 'fail': + return ; + case 'warn': + return ; + default: + return
; + } + }; + + const getCheckIcon = (id: string) => { + switch (id) { + case 'https': + return ; + case 'reachable': + return ; + case 'cors': + return ; + default: + return null; + } + }; + + const allPassed = results.every(r => r.status === 'pass' || r.status === 'warn'); + const hasFailed = results.some(r => r.status === 'fail'); + + return ( +
+
+ {isChecking ? ( + + ) : allPassed ? ( + + ) : hasFailed ? ( + + ) : ( + + )} + +

+ {isChecking ? 'Checking your URL...' : + allPassed ? 'Looking good!' : + hasFailed ? 'Some issues found' : + 'URL Check Results'} +

+
+ +
+ {results.map(result => ( +
+ {getCheckIcon(result.id)} + +
+ {result.label} +
+ +
+ {result.message && ( + + {result.message} + + )} + + {getIcon(isChecking && result.status === 'pending' ? 'checking' : result.status)} +
+
+ ))} +
+ + {!isChecking && hasFailed && ( +

+ Fix the issues above before continuing. See the header examples below. +

+ )} + + {!isChecking && allPassed && ( +

+ Your URL passed basic checks. You may still need to configure iframe headers (X-Frame-Options). +

+ )} +
+ ); +}; + +type AppType = 'existing' | 'new' | null; + +// Feature definitions +interface Feature { + id: string; + title: string; + description: string; + icon: React.ReactNode; + requiresSetup: boolean; + setupDescription?: string; + color: string; + comingSoon?: boolean; +} + +const FEATURES: Feature[] = [ + { + id: 'issue-credentials', + title: 'Issue Credentials', + description: 'Award badges, certificates, or achievements to your users when they complete actions in your app.', + icon: , + requiresSetup: true, + setupDescription: 'Create Templates, Consent Flow', + color: 'cyan', + }, + { + id: 'peer-badges', + title: 'Peer-to-Peer Badges', + description: 'Let users send badges to each other within your app using your credential templates.', + icon: , + requiresSetup: true, + setupDescription: 'Create Boost Templates', + color: 'violet', + }, + { + id: 'request-credentials', + title: 'Request Credentials', + description: 'Ask users to share credentials with your app for verification or gated access.', + icon: , + requiresSetup: true, + setupDescription: 'Configure Search Query', + color: 'amber', + }, + { + id: 'request-data-consent', + title: 'Request Data Consent', + description: 'Ask users for permission to access specific data fields or write data back to their profile via a ConsentFlow contract.', + icon: , + requiresSetup: true, + setupDescription: 'Define Consent Contract', + color: 'emerald', + }, + { + id: 'launch-feature', + title: 'Launch Feature', + description: 'Trigger native LearnCard tools directly from your app. Open the QR scanner, start an AI session, or display the profile card.', + icon: , + requiresSetup: true, + setupDescription: 'Configure Feature Settings', + color: 'purple', + }, + { + id: 'display-pathways', + title: 'Display Pathways', + description: 'Visualize a user\'s journey. Show completed steps and what credentials they need to reach a goal.', + icon: , + requiresSetup: true, + setupDescription: 'Define Pathway/Map Structure', + color: 'rose', + comingSoon: true, + }, + { + id: 'launch-ai-assistant', + title: 'Launch AI Assistant', + description: 'Embed a custom AI chat or tutor experience. Configure preset prompts and context for "Math Tutor" or "Career Coach" style interactions.', + icon: , + requiresSetup: true, + setupDescription: 'Define AI Prompt & Context', + color: 'indigo', + comingSoon: true, + }, +]; + +const STEPS = [ + { id: 'getting-started', title: 'Getting Started' }, + { id: 'choose-features', title: 'Choose Features' }, + { id: 'feature-setup', title: 'Feature Setup' }, + { id: 'your-app', title: 'Your App' }, +]; + +// Step 0: Getting Started (combined setup step - single scrollable page) +const GettingStartedStep: React.FC<{ + onComplete: () => void; + selectedIntegration: LCNIntegration | null; + selectedListing: AppStoreListing | null; + setSelectedListing: (listing: AppStoreListing | null) => void; +}> = ({ onComplete, selectedIntegration, selectedListing, setSelectedListing }) => { + const [copiedCode, setCopiedCode] = useState(null); + + // Listing management + const { useListingsForIntegration, useCreateListing } = useDeveloperPortal(); + const { data: listings, isLoading: isLoadingListings, refetch: refetchListings } = useListingsForIntegration(selectedIntegration?.id || null); + const createListingMutation = useCreateListing(); + const [isCreatingListing, setIsCreatingListing] = useState(false); + const [newListingName, setNewListingName] = useState(''); + + // Auto-select first listing when integration changes + useEffect(() => { + if (listings && listings.length > 0 && !selectedListing) { + setSelectedListing(listings[0]); + } + }, [listings, selectedListing, setSelectedListing]); + + // Clear listing when integration changes + useEffect(() => { + setSelectedListing(null); + }, [selectedIntegration?.id]); + + const handleCreateListing = async () => { + if (!newListingName.trim() || !selectedIntegration) return; + + try { + await createListingMutation.mutateAsync({ + integrationId: selectedIntegration.id, + listing: { + display_name: newListingName.trim(), + tagline: `${newListingName.trim()} - An embedded LearnCard app`, + full_description: `${newListingName.trim()} is an embedded application that integrates with the LearnCard wallet.`, + icon_url: 'https://cdn.filestackcontent.com/Ja9TRvGVRsuncjqpxedb', + launch_type: 'EMBEDDED_IFRAME', + launch_config_json: JSON.stringify({ url: '' }), + }, + }); + + await refetchListings(); + setNewListingName(''); + setIsCreatingListing(false); + } catch (err) { + console.error('Failed to create listing:', err); + } + }; + + const handleCopy = async (code: string, id: string) => { + await navigator.clipboard.writeText(code); + setCopiedCode(id); + setTimeout(() => setCopiedCode(null), 2000); + }; + + const installCode = `npm install @learncard/partner-connect`; + + const initCode = `import { createPartnerConnect } from '@learncard/partner-connect'; + +const learnCard = createPartnerConnect({ + hostOrigin: 'https://learncard.app' +}); + +// Get user identity (SSO - no login needed!) +const identity = await learnCard.requestIdentity(); +console.log('User:', identity.profile.displayName);`; + + const isReady = !!selectedIntegration && !!selectedListing; + + return ( +
+
+

Getting Started

+ +

+ Set up the Partner Connect SDK in your web app. This takes about 2 minutes. +

+
+ + {/* Section 1: Select/Create App */} +
+
+
+ 1 +
+ +

Select or Create Your App

+
+ +
+

+ Your app listing is what users see in the LearnCard app store. +

+ + {!selectedIntegration ? ( +
+

+ Select a project from the header dropdown first. +

+
+ ) : isLoadingListings ? ( +
+ +
+ ) : ( +
+ {/* App Listings */} + {listings && listings.length > 0 && ( +
+ {listings.map((listing) => { + const isSelected = selectedListing?.listing_id === listing.listing_id; + + return ( + + ); + })} +
+ )} + + {/* Empty State */} + {(!listings || listings.length === 0) && !isCreatingListing && ( +
+ + +

No apps yet

+ +

+ Create your first app to get started +

+
+ )} + + {/* Create New App Form */} + {isCreatingListing ? ( +
+
+ + + setNewListingName(e.target.value)} + placeholder="My Awesome App" + className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-500" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateListing(); + if (e.key === 'Escape') setIsCreatingListing(false); + }} + /> +
+ +
+ + + +
+
+ ) : ( + + )} +
+ )} +
+
+ + {/* Section 2: Install SDK */} +
+
+
+ 2 +
+ +

Install the SDK

+
+ +
+ + +

+ Also works with yarn add or pnpm add +

+
+
+ + {/* Section 3: Initialize */} +
+
+
+ 3 +
+ +

Initialize

+
+ +
+ + +
+

+ That's it! Users are already logged in when inside the wallet, so requestIdentity() returns instantly with their profile. +

+
+
+
+ + {/* Continue button */} + +
+ ); +}; + +// Step 1: Choose Features (the hub) +const ChooseFeaturesStep: React.FC<{ + onComplete: () => void; + onBack: () => void; + selectedFeatures: string[]; + setSelectedFeatures: (features: string[]) => void; +}> = ({ onComplete, onBack, selectedFeatures, setSelectedFeatures }) => { + const toggleFeature = (featureId: string) => { + const feature = FEATURES.find(f => f.id === featureId); + + if (feature?.comingSoon) return; // Don't allow selection of coming soon features + + if (selectedFeatures.includes(featureId)) { + setSelectedFeatures(selectedFeatures.filter(f => f !== featureId)); + } else { + setSelectedFeatures([...selectedFeatures, featureId]); + } + }; + + const getColorClasses = (color: string, isSelected: boolean, isComingSoon: boolean = false) => { + const colors: Record = { + cyan: { border: 'border-cyan-500', bg: 'bg-cyan-50', icon: 'text-cyan-600 bg-cyan-100' }, + violet: { border: 'border-violet-500', bg: 'bg-violet-50', icon: 'text-violet-600 bg-violet-100' }, + purple: { border: 'border-purple-500', bg: 'bg-purple-50', icon: 'text-purple-600 bg-purple-100' }, + emerald: { border: 'border-emerald-500', bg: 'bg-emerald-50', icon: 'text-emerald-600 bg-emerald-100' }, + amber: { border: 'border-amber-500', bg: 'bg-amber-50', icon: 'text-amber-600 bg-amber-100' }, + rose: { border: 'border-rose-500', bg: 'bg-rose-50', icon: 'text-rose-600 bg-rose-100' }, + indigo: { border: 'border-indigo-500', bg: 'bg-indigo-50', icon: 'text-indigo-600 bg-indigo-100' }, + }; + + const c = colors[color] || colors.cyan; + + if (isComingSoon) { + return { border: 'border-gray-200 border-dashed', bg: 'bg-gray-50', icon: 'text-gray-400 bg-gray-100' }; + } + + return isSelected + ? { border: c.border, bg: c.bg, icon: c.icon } + : { border: 'border-gray-200', bg: 'bg-white', icon: 'text-gray-500 bg-gray-100' }; + }; + + const hasFeatureWithSetup = selectedFeatures.some(id => + FEATURES.find(f => f.id === id)?.requiresSetup + ); + + return ( +
+
+

What do you want to build?

+ +

+ Select the features you want to add to your app. You can always add more later. +

+
+ + {/* Feature cards */} +
+ {FEATURES.map(feature => { + const isSelected = selectedFeatures.includes(feature.id); + const isComingSoon = feature.comingSoon === true; + const colors = getColorClasses(feature.color, isSelected, isComingSoon); + + return ( + + ); + })} +
+ + {/* Selection summary */} + {selectedFeatures.length > 0 && ( +
+

+ Selected: {selectedFeatures.length} feature{selectedFeatures.length !== 1 ? 's' : ''} +

+ +
+ {selectedFeatures.map(id => { + const feature = FEATURES.find(f => f.id === id); + + return feature ? ( + + {feature.title} + + ) : null; + })} +
+ + {hasFeatureWithSetup && ( +

+ Some features require additional setup. We'll guide you through it. +

+ )} +
+ )} + + {/* Navigation */} +
+ + + +
+
+ ); +}; + +// Legacy step components below (keeping for reference, not used in new flow) +// TODO: Remove these in a future cleanup + +const SetupWebsiteStep: React.FC<{ + onComplete: () => void; + onBack: () => void; + appUrl: string; + setAppUrl: (url: string) => void; + appType: AppType; + selectedFramework: string; + setSelectedFramework: (framework: string) => void; +}> = ({ onComplete, onBack, appUrl, setAppUrl, appType, selectedFramework, setSelectedFramework }) => { + const frameworks = [ + { id: 'react', name: 'React', icon: '⚛️', cmd: 'npx create-react-app my-learncard-app' }, + { id: 'next', name: 'Next.js', icon: '▲', cmd: 'npx create-next-app@latest my-learncard-app' }, + { id: 'vite', name: 'Vite', icon: '⚡', cmd: 'npm create vite@latest my-learncard-app' }, + { id: 'vue', name: 'Vue', icon: '💚', cmd: 'npm create vue@latest my-learncard-app' }, + ]; + + // New app path + if (appType === 'new') { + return ( +
+
+

Create Your App

+ +

+ Choose a framework and we'll give you the commands to get started with a pre-configured setup. +

+
+ + {/* Framework selector */} +
+ + +
+ {frameworks.map(fw => ( + + ))} +
+
+ + {/* Commands based on selection */} + {selectedFramework && ( +
+ f.id === selectedFramework)?.name} project +${frameworks.find(f => f.id === selectedFramework)?.cmd} + +cd my-learncard-app`, + }} + /> + + + + {selectedFramework === 'next' && ( + + )} + + {selectedFramework === 'vite' && ( + + )} + + {(selectedFramework === 'react' || selectedFramework === 'vue') && ( +
+

Configure headers on your server

+ +

+ When you deploy, you'll need to configure your hosting provider to add these headers. + Most providers (Vercel, Netlify, etc.) support this in their config files. +

+
+ )} +
+ )} + + {/* App name input */} +
+ + + setAppUrl(e.target.value)} + placeholder="My LearnCard App" + className="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-violet-500" + /> +
+ + {/* Navigation */} +
+ + + +
+
+ ); + } + + // Existing app path - state for URL checking + const [isChecking, setIsChecking] = useState(false); + const [checkResults, setCheckResults] = useState(null); + + const handleCheckUrl = useCallback(async () => { + if (!appUrl.trim()) return; + + setIsChecking(true); + setCheckResults([ + { id: 'https', label: 'HTTPS', status: 'pending' }, + { id: 'reachable', label: 'Reachable', status: 'pending' }, + { id: 'cors', label: 'CORS Headers', status: 'pending' }, + ]); + + const results = await checkUrl(appUrl.trim()); + setCheckResults(results); + setIsChecking(false); + }, [appUrl]); + + // Auto-check when URL looks complete + const handleUrlChange = (e: React.ChangeEvent) => { + const newUrl = e.target.value; + setAppUrl(newUrl); + + // Reset results when URL changes + if (checkResults) { + setCheckResults(null); + } + }; + + return ( +
+
+

Configure Your Existing App

+ +

+ Your app will run inside an iframe in the LearnCard wallet. Enter your URL and we'll + check if it's ready for embedding. +

+
+ + {/* URL input with check button */} +
+ + +
+ { + if (e.key === 'Enter' && appUrl.trim()) { + handleCheckUrl(); + } + }} + /> + + +
+ +

+ Enter your URL and click Check to verify requirements +

+
+ + {/* URL Check Results */} + {checkResults && ( + + )} + + {/* Required headers */} +
+

Required Response Headers

+ +

+ Your server must return these headers to allow iframe embedding: +

+ + { + // Allow iframe embedding from any origin + res.setHeader('X-Frame-Options', 'ALLOWALL'); + res.setHeader('Content-Security-Policy', "frame-ancestors *"); + + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + next(); +});`, + curl: `# Nginx configuration +location / { + add_header X-Frame-Options "ALLOWALL"; + add_header Content-Security-Policy "frame-ancestors *"; + add_header Access-Control-Allow-Origin "*"; +}`, + python: `# Flask example +from flask import Flask +from flask_cors import CORS + +app = Flask(__name__) +CORS(app) + +@app.after_request +def add_headers(response): + response.headers['X-Frame-Options'] = 'ALLOWALL' + response.headers['Content-Security-Policy'] = 'frame-ancestors *' + return response`, + }} + defaultLanguage="typescript" + /> +
+ + {/* Common issues */} +
+

Common Issues

+ +
+
+ ! +
+ Blank iframe? + — Check your X-Frame-Options header isn't set to DENY or SAMEORIGIN +
+
+ +
+ ! +
+ Mixed content error? + — Make sure your app uses HTTPS +
+
+ +
+ ! +
+ CORS errors? + — Add Access-Control-Allow-Origin header +
+
+
+
+ + {/* Navigation */} +
+ + + +
+
+ ); +}; + +// Step 2: Install SDK +const InstallSdkStep: React.FC<{ + onComplete: () => void; + onBack: () => void; +}> = ({ onComplete, onBack }) => { + return ( +
+
+

Install the SDK

+ +

+ The Partner Connect SDK lets your embedded app communicate with the LearnCard wallet. +

+
+ + + + {/* What you get */} +
+

What's included

+ +
+
+ +
+

Single Sign-On

+

Get user identity instantly

+
+
+ +
+ +
+

Send Credentials

+

Issue VCs to the wallet

+
+
+ +
+ +
+

Request Credentials

+

Search user's wallet

+
+
+ +
+ +
+

Navigation

+

Launch wallet features

+
+
+
+
+ + {/* Navigation */} +
+ + + +
+
+ ); +}; + +// Step 3: Initialize +const InitializeStep: React.FC<{ + onComplete: () => void; + onBack: () => void; +}> = ({ onComplete, onBack }) => { + const initCode = `import { createPartnerConnect } from '@learncard/partner-connect'; + +// Initialize the SDK +const learnCard = createPartnerConnect({ + hostOrigin: 'https://learncard.app' +}); + +// Request user identity (like SSO) +const identity = await learnCard.requestIdentity(); + +console.log('User DID:', identity.did); +console.log('User Profile:', identity.profile); +// profile contains: displayName, profileId, image, etc.`; + + return ( +
+
+

Initialize the SDK

+ +

+ Set up the SDK when your app loads. You can immediately request the user's identity — + no login required since they're already in the wallet. +

+
+ + + + {/* Identity response example */} +
+

Example Response

+ + +
+ + {/* Navigation */} +
+ + + +
+
+ ); +}; + +// Boost Template type +interface BoostTemplate { + uri: string; + name: string; + description?: string; + type?: string; + category?: string; + image?: string; + createdAt?: string; +} + +// Template Manager Component - supports both initiateTemplateIssue and send() styles +type TemplateCodeStyle = 'initiateTemplateIssue' | 'send'; + +const TemplateManager: React.FC<{ + appListingId?: string; + appName?: string; + codeStyle?: TemplateCodeStyle; + contractUri?: string; +}> = ({ appListingId, appName, codeStyle = 'initiateTemplateIssue', contractUri }) => { + const { initWallet } = useWallet(); + const { presentToast } = useToast(); + + const [templates, setTemplates] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [showCredentialBuilder, setShowCredentialBuilder] = useState(false); + const [copiedUri, setCopiedUri] = useState(null); + const [deletingUri, setDeletingUri] = useState(null); + const [copiedJson, setCopiedJson] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Store initWallet in a ref to avoid dependency issues + const initWalletRef = React.useRef(initWallet); + initWalletRef.current = initWallet; + + // Helper to fetch templates + const fetchTemplatesForListing = async (listingId: string): Promise => { + const wallet = await initWalletRef.current(); + const result = await wallet.invoke.getPaginatedBoosts({ + limit: 100, + query: { meta: { appListingId: listingId } } + }); + + return (result?.records || []) + .map((boost: Record) => ({ + uri: boost.uri as string, + name: boost.name as string || 'Untitled Template', + description: boost.description as string, + type: boost.type as string, + category: boost.category as string, + image: boost.image as string, + createdAt: boost.createdAt as string, + })); + }; + + // Fetch templates when appListingId changes + useEffect(() => { + let cancelled = false; + + if (!appListingId) { + setTemplates([]); + setIsLoading(false); + return; + } + + setIsLoading(true); + + fetchTemplatesForListing(appListingId) + .then((boostTemplates) => { + if (!cancelled) { + setTemplates(boostTemplates); + } + }) + .catch((err) => { + console.error('Failed to fetch templates:', err); + if (!cancelled) { + setTemplates([]); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [appListingId]); + + // Manual refresh function + const refreshTemplates = async () => { + if (!appListingId) return; + + setIsLoading(true); + + try { + const boostTemplates = await fetchTemplatesForListing(appListingId); + setTemplates(boostTemplates); + } catch (err) { + console.error('Failed to refresh templates:', err); + } finally { + setIsLoading(false); + } + }; + + // Create a new boost template from credential + const handleCreateTemplate = async (credential: Record) => { + if (!appListingId) { + presentToast('No app listing selected', { type: ToastTypeEnum.Error }); + return; + } + + setIsCreating(true); + + try { + const wallet = await initWallet(); + + // The OBv3CredentialBuilder now outputs properly structured credentials + // Just issue it directly + const vc = await wallet.invoke.issueCredential(credential as Parameters[0]); + console.log("Issued credential:", vc); + // Create the boost with public issuance permission + const boostMetadata = { + name: (credential.name as string) || 'Template', + type: ((credential.credentialSubject as Record)?.achievement as Record)?.achievementType as string || 'Achievement', + category: 'achievement', + meta: { appListingId }, // Store app listing ID in metadata for filtering + defaultPermissions: { + canIssue: true, // Public template - anyone can issue + }, + }; + const boostUri = await wallet.invoke.createBoost(vc, boostMetadata as unknown as Parameters[1]); + + presentToast('Template created successfully!', { type: ToastTypeEnum.Success }); + + // Refresh the list + await refreshTemplates(); + + setShowCredentialBuilder(false); + } catch (err) { + console.error('Failed to create template:', err); + presentToast('Failed to create template', { type: ToastTypeEnum.Error }); + } finally { + setIsCreating(false); + } + }; + + // Delete a template + const handleDeleteTemplate = async (uri: string) => { + setDeletingUri(uri); + + try { + const wallet = await initWallet(); + await wallet.invoke.deleteBoost(uri); + presentToast('Template deleted', { type: ToastTypeEnum.Success }); + await refreshTemplates(); + } catch (err) { + console.error('Failed to delete template:', err); + presentToast('Failed to delete template', { type: ToastTypeEnum.Error }); + } finally { + setDeletingUri(null); + } + }; + + // Copy URI to clipboard + const handleCopyUri = async (uri: string) => { + await navigator.clipboard.writeText(uri); + setCopiedUri(uri); + setTimeout(() => setCopiedUri(null), 2000); + }; + + // Generate code example for a template based on code style + const getCodeExample = (uri: string) => { + if (codeStyle === 'send') { + return `// Send credential to user's wallet (after consent) +const result = await learnCard.invoke.send({ + type: 'boost', + recipient: recipientDid, // From requestIdentity() + templateUri: '${uri}', + contractUri: '${contractUri || 'YOUR_CONTRACT_URI'}', +}); + +console.log('Credential synced:', result);`; + } + + // Default: initiateTemplateIssue style + return `// Issue from your template +const result = await learnCard.initiateTemplateIssue({ + templateUri: '${uri}' +}); + +if (result.success) { + console.log('Credential issued to user!'); +}`; + }; + + // Generate JSON summary of all templates + const getJsonSummary = () => JSON.stringify( + templates.map(t => ({ + boostUri: t.uri, + name: t.name, + description: t.description || '', + image: t.image || '', + })), + null, + 2 + ); + + // Copy JSON summary + const handleCopyJson = async () => { + await navigator.clipboard.writeText(getJsonSummary()); + setCopiedJson(true); + setTimeout(() => setCopiedJson(false), 2000); + }; + + // Server-side code example + const getServerSideCode = () => `// Server-side: Retrieve all boost templates for your app +import { initLearnCard } from '@learncard/init'; + +async function getAppBoostTemplates(appListingId: string) { + // Initialize LearnCard with your credentials + const learnCard = await initLearnCard({ + // Your authentication config + }); + + // Fetch boosts filtered by appListingId in meta + const result = await learnCard.invoke.getPaginatedBoosts({ + limit: 100, + query: { meta: { appListingId } } + }); + + // Map to your desired format + const templates = (result?.records || []).map(boost => ({ + boostUri: boost.uri, + name: boost.name, + description: boost.description || '', + image: boost.image || '', + type: boost.type, + })); + + return templates; +} + +// Usage +const templates = await getAppBoostTemplates('${appListingId}'); +console.log('Available templates:', templates);`; + + if (!appListingId) { + return ( +
+
+ + +
+

App Listing Required

+ +

+ To create and manage boost templates, select an integration and app listing above. +

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Your Boost Templates

+ +

+ Create credential templates that your embedded app can issue to users +

+
+ +
+ {templates.length > 0 && ( + + )} + + + + +
+
+ + {/* Loading state */} + {isLoading && ( +
+ +
+ )} + + {/* Empty state */} + {!isLoading && templates.length === 0 && ( +
+
+ +
+ +

No templates yet

+ +

+ Create your first boost template to start issuing credentials from your app +

+ + +
+ )} + + {/* Template list */} + {!isLoading && templates.length > 0 && ( +
+ {templates.map(template => ( +
+
+ {/* Template image */} +
+ {template.image ? ( + {template.name} + ) : ( + + )} +
+ + {/* Template info */} +
+
+ {template.name} +
+ +
+ {template.type && ( + + {template.type} + + )} + + + {template.uri.slice(0, 30)}... + +
+
+ + {/* Actions */} +
+ + + +
+
+ + {/* Code example (collapsed by default, could expand) */} +
+
+ Use in your app: +
+ + +
+
+ ))} +
+ )} + + {/* Advanced Section - Server-side code */} + {templates.length > 0 && ( +
+ + + {showAdvanced && ( +
+

+ Use this server-side function to dynamically retrieve all boost templates for your app listing. + This is useful for building template pickers or syncing templates to your database. +

+ +
+
+ Server-side code (Node.js/TypeScript): +
+ + +
+
+ )} +
+ )} + + {/* Credential Builder Modal */} + setShowCredentialBuilder(false)} + onSave={handleCreateTemplate} + /> + + {/* Creating overlay */} + {isCreating && ( +
+
+ + Creating template... +
+
+ )} +
+ ); +}; + +// API Method types +interface ApiMethod { + id: string; + name: string; + category: 'auth' | 'credentials' | 'navigation' | 'consent'; + icon: React.ReactNode; + shortDescription: string; + description: string; + parameters: Array<{ + name: string; + type: string; + required: boolean; + description: string; + }>; + returns: { + type: string; + description: string; + example: string; + }; + code: string; + tips?: string[]; +} + +// Step 4: Use API - Stripe-docs style +const UseApiStep: React.FC<{ + onBack: () => void; +}> = ({ onBack }) => { + const [selectedMethodId, setSelectedMethodId] = useState('requestIdentity'); + const [showTemplateManager, setShowTemplateManager] = useState(false); + + // Integration and App Listing state for template management + const [selectedIntegration, setSelectedIntegration] = useState(null); + const [selectedListing, setSelectedListing] = useState(null); + const [isCreatingIntegration, setIsCreatingIntegration] = useState(false); + const [isCreatingListing, setIsCreatingListing] = useState(false); + const [newIntegrationName, setNewIntegrationName] = useState(''); + const [newListingName, setNewListingName] = useState(''); + + // Developer portal hooks + const { + useIntegrations, + useCreateIntegration, + useListingsForIntegration, + useCreateListing, + } = useDeveloperPortal(); + const { data: integrations, isLoading: isLoadingIntegrations, refetch: refetchIntegrations } = useIntegrations(); + const createIntegrationMutation = useCreateIntegration(); + const { data: listings, isLoading: isLoadingListings, refetch: refetchListings } = useListingsForIntegration(selectedIntegration?.id || null); + const createListingMutation = useCreateListing(); + + // Auto-select first integration + useEffect(() => { + if (integrations && integrations.length > 0 && !selectedIntegration) { + setSelectedIntegration(integrations[0]); + } + }, [integrations, selectedIntegration]); + + // Auto-select first listing when integration changes + useEffect(() => { + if (listings && listings.length > 0 && !selectedListing) { + setSelectedListing(listings[0]); + } else if (!listings || listings.length === 0) { + setSelectedListing(null); + } + }, [listings, selectedListing, selectedIntegration]); + + const handleCreateIntegration = async () => { + if (!newIntegrationName.trim()) return; + try { + await createIntegrationMutation.mutateAsync(newIntegrationName.trim()); + setNewIntegrationName(''); + setIsCreatingIntegration(false); + refetchIntegrations(); + } catch (err) { + console.error('Failed to create integration:', err); + } + }; + + const handleCreateListing = async () => { + if (!newListingName.trim() || !selectedIntegration) return; + try { + await createListingMutation.mutateAsync({ + integrationId: selectedIntegration.id, + listing: { + display_name: newListingName.trim(), + tagline: `${newListingName.trim()} - An embedded LearnCard app`, + full_description: `${newListingName.trim()} is an embedded application that integrates with the LearnCard wallet.`, + icon_url: 'https://cdn.filestackcontent.com/Ja9TRvGVRsuncjqpxedb', // Default LearnCard icon + launch_type: 'EMBEDDED_IFRAME', + launch_config_json: JSON.stringify({ url: '' }), + }, + }); + setNewListingName(''); + setIsCreatingListing(false); + refetchListings(); + } catch (err) { + console.error('Failed to create listing:', err); + } + }; + + const categories = [ + { id: 'auth', name: 'Authentication', icon: }, + { id: 'credentials', name: 'Credentials', icon: }, + { id: 'navigation', name: 'Navigation', icon: }, + { id: 'consent', name: 'Consent', icon: }, + ]; + + const methods: ApiMethod[] = [ + // Authentication + { + id: 'requestIdentity', + name: 'requestIdentity', + category: 'auth', + icon: , + shortDescription: 'SSO authentication', + description: 'Request the user\'s identity for single sign-on. Since the user is already authenticated in the LearnCard wallet, this instantly returns their DID and profile information — no login flow required.', + parameters: [], + returns: { + type: 'Promise', + description: 'User identity object with DID and profile', + example: `{ + "did": "did:web:network.learncard.com:users:abc123", + "profile": { + "displayName": "Jane Smith", + "profileId": "janesmith", + "image": "https://cdn.learncard.com/avatars/abc123.png", + "email": "jane@example.com" + } +}`, + }, + code: `import { createPartnerConnect } from '@learncard/partner-connect'; + +const learnCard = createPartnerConnect({ + hostOrigin: 'https://learncard.app' +}); + +// Get the authenticated user's identity +const identity = await learnCard.requestIdentity(); + +// Use the identity in your app +console.log('Welcome,', identity.profile.displayName); +console.log('User DID:', identity.did); + +// You can use the DID as a unique user identifier +const userId = identity.did;`, + tips: [ + 'Call this on app load to immediately identify the user', + 'The DID is a unique, cryptographic identifier for the user', + 'Profile data may be partial depending on user privacy settings', + ], + }, + // Credentials + { + id: 'sendCredential', + name: 'sendCredential', + category: 'credentials', + icon: , + shortDescription: 'Issue a credential', + description: 'Send a Verifiable Credential directly to the user\'s wallet. The user will see a prompt to accept the credential. Use this for course completions, achievements, certificates, and more.', + parameters: [ + { + name: 'credential', + type: 'VerifiableCredential', + required: true, + description: 'The W3C Verifiable Credential object to send', + }, + ], + returns: { + type: 'Promise<{ success: boolean }>', + description: 'Whether the credential was accepted', + example: `{ "success": true }`, + }, + code: `// Issue a credential when user completes something +const credential = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json" + ], + "type": ["VerifiableCredential", "OpenBadgeCredential"], + "name": "JavaScript Fundamentals", + "issuer": { + "id": "did:web:your-app.com", + "name": "Your App Name" + }, + "credentialSubject": { + "achievement": { + "type": ["Achievement"], + "name": "JavaScript Fundamentals", + "description": "Completed the JavaScript fundamentals course", + "achievementType": "Certificate" + } + } +}; + +const result = await learnCard.sendCredential({ credential }); + +if (result.success) { + showSuccessMessage('Credential added to your wallet!'); +}`, + tips: [ + 'Use Open Badges 3.0 format for maximum compatibility', + 'Include your issuer DID for credential verification', + 'The user can decline — always handle the rejection case', + ], + }, + { + id: 'askCredentialSearch', + name: 'askCredentialSearch', + category: 'credentials', + icon: , + shortDescription: 'Query user credentials', + description: 'Request access to search the user\'s credential wallet. The user will see a consent prompt and can choose which credentials to share. Great for verification flows or importing existing credentials.', + parameters: [ + { + name: 'query', + type: 'CredentialQuery', + required: false, + description: 'Optional filter criteria for credential types', + }, + ], + returns: { + type: 'Promise<{ credentials: VerifiableCredential[] }>', + description: 'Array of credentials the user chose to share', + example: `{ + "credentials": [ + { + "@context": [...], + "type": ["VerifiableCredential", "OpenBadgeCredential"], + "name": "Python Developer Certificate", + ... + } + ] +}`, + }, + code: `// Search for specific credential types +const result = await learnCard.askCredentialSearch({ + type: ['OpenBadgeCredential'] +}); + +// User selects which credentials to share +if (result.credentials.length > 0) { + console.log('User shared', result.credentials.length, 'credentials'); + + // Process the shared credentials + for (const cred of result.credentials) { + console.log('Credential:', cred.name); + // Verify, display, or store the credential + } +} else { + console.log('User declined or has no matching credentials'); +}`, + tips: [ + 'Users control what they share — respect their privacy', + 'Filter by type to only request relevant credentials', + 'Credentials are cryptographically verifiable', + ], + }, + { + id: 'askCredentialSpecific', + name: 'askCredentialSpecific', + category: 'credentials', + icon: , + shortDescription: 'Get credential by ID', + description: 'Request a specific credential by its ID. Useful when you know exactly which credential you need, such as re-verifying a previously shared credential.', + parameters: [ + { + name: 'credentialId', + type: 'string', + required: true, + description: 'The unique ID of the credential to request', + }, + ], + returns: { + type: 'Promise<{ credential: VerifiableCredential | null }>', + description: 'The requested credential if user approves', + example: `{ + "credential": { + "@context": [...], + "id": "urn:uuid:abc123...", + "type": ["VerifiableCredential"], + ... + } +}`, + }, + code: `// Request a specific credential by ID +const credentialId = 'urn:uuid:abc123-def456-...'; + +const result = await learnCard.askCredentialSpecific(credentialId); + +if (result.credential) { + console.log('Got credential:', result.credential.name); + + // Verify the credential is still valid + const isValid = await verifyCredential(result.credential); + + if (isValid) { + grantAccess(); + } +} else { + console.log('User declined or credential not found'); +}`, + tips: [ + 'Store credential IDs to re-verify later', + 'User must still approve sharing the credential', + 'Returns null if credential doesn\'t exist in user\'s wallet', + ], + }, + { + id: 'initiateTemplateIssue', + name: 'initiateTemplateIssue', + category: 'credentials', + icon: , + shortDescription: 'Issue from template', + description: 'Issue a credential using a pre-defined boost template. Templates are configured in the LearnCard dashboard and ensure consistent credential formatting. Best for recurring credential types.', + parameters: [ + { + name: 'templateUri', + type: 'string', + required: true, + description: 'The URI of the boost/template to issue from', + }, + { + name: 'recipientDid', + type: 'string', + required: false, + description: 'DID of the recipient (defaults to current user)', + }, + ], + returns: { + type: 'Promise<{ success: boolean, credentialId?: string }>', + description: 'Result of the issuance', + example: `{ + "success": true, + "credentialId": "urn:uuid:new-cred-123..." +}`, + }, + code: `// Issue from a pre-configured template +const templateUri = 'lc:boost:your-org:course-completion-template'; + +const result = await learnCard.initiateTemplateIssue({ + templateUri, + // Optionally specify recipient (defaults to current user) + // recipientDid: 'did:web:...' +}); + +if (result.success) { + console.log('Credential issued:', result.credentialId); + showSuccess('Achievement unlocked!'); +}`, + tips: [ + 'Create templates in the LearnCard dashboard first', + 'Templates ensure consistent branding and fields', + 'Great for gamification with pre-defined achievements', + ], + }, + // Navigation + { + id: 'launchFeature', + name: 'launchFeature', + category: 'navigation', + icon: , + shortDescription: 'Navigate host app', + description: 'Navigate the LearnCard wallet to a specific feature or page. This allows your app to integrate with wallet features like viewing credentials, managing contacts, or accessing settings.', + parameters: [ + { + name: 'path', + type: 'string', + required: true, + description: 'The wallet path to navigate to', + }, + { + name: 'description', + type: 'string', + required: false, + description: 'Optional description shown during navigation', + }, + ], + returns: { + type: 'Promise', + description: 'Resolves when navigation completes', + example: `// No return value`, + }, + code: `// Navigate to the user's credential wallet +await learnCard.launchFeature('/wallet', 'View your credentials'); + +// Open the contacts/connections page +await learnCard.launchFeature('/contacts', 'Find and connect with others'); + +// Open settings +await learnCard.launchFeature('/settings', 'Manage your preferences'); + +// Open a specific credential detail +await learnCard.launchFeature('/credential/abc123', 'View credential details'); + +// Available paths: +// /wallet - Credential wallet +// /contacts - Connections & contacts +// /settings - User settings +// /profile - User profile +// /activity - Activity feed`, + tips: [ + 'Use this to complement your app\'s features with wallet features', + 'The description appears as a toast or transition message', + 'Navigation happens within the wallet, not your iframe', + ], + }, + // Consent + { + id: 'requestConsent', + name: 'requestConsent', + category: 'consent', + icon: , + shortDescription: 'Request permissions', + description: 'Request user consent for specific permissions or data access. Consent is tied to a contract URI that defines what access is being granted. Use this for ongoing data access agreements.', + parameters: [ + { + name: 'contractUri', + type: 'string', + required: true, + description: 'The URI of the consent contract', + }, + { + name: 'options', + type: 'ConsentOptions', + required: false, + description: 'Additional options like scope and duration', + }, + ], + returns: { + type: 'Promise<{ granted: boolean, consentId?: string }>', + description: 'Whether consent was granted', + example: `{ + "granted": true, + "consentId": "consent:abc123..." +}`, + }, + code: `// Request consent for a data sharing agreement +const result = await learnCard.requestConsent('lc:contract:your-app:data-access', { + scope: ['profile', 'credentials'], + duration: '30d' // 30 days +}); + +if (result.granted) { + console.log('Consent granted! ID:', result.consentId); + + // Store the consent ID for future reference + await saveUserConsent(userId, result.consentId); + + // Now you can access data per the contract terms + enablePremiumFeatures(); +} else { + console.log('User declined consent'); + showLimitedFeatures(); +}`, + tips: [ + 'Be clear about what access you\'re requesting', + 'Users can revoke consent at any time', + 'Store consent IDs to track active agreements', + ], + }, + ]; + + const selectedMethod = methods.find(m => m.id === selectedMethodId) || methods[0]; + + const getCategoryColor = (category: string) => { + switch (category) { + case 'auth': return 'text-violet-600 bg-violet-100'; + case 'credentials': return 'text-cyan-600 bg-cyan-100'; + case 'navigation': return 'text-amber-600 bg-amber-100'; + case 'consent': return 'text-emerald-600 bg-emerald-100'; + default: return 'text-gray-600 bg-gray-100'; + } + }; + + return ( +
+ {/* Header */} +
+

Partner Connect API Reference

+ +

+ Complete API for communicating with the LearnCard wallet. Select a method to see detailed documentation and code examples. +

+
+ + {/* Split-screen layout */} +
+ {/* Left: Method navigation */} +
+ {categories.map(category => { + const categoryMethods = methods.filter(m => m.category === category.id); + + return ( +
+
+ {category.icon} + {category.name} +
+ +
+ {categoryMethods.map(method => ( + + ))} +
+
+ ); + })} +
+ + {/* Right: Method details */} +
+ {/* Method header */} +
+
+
+ {selectedMethod.icon} +
+ +
+

+ learnCard.{selectedMethod.name}() +

+ +

+ {selectedMethod.description} +

+
+
+
+ + {/* Parameters */} + {selectedMethod.parameters.length > 0 && ( +
+
+ + Parameters +
+ +
+ {selectedMethod.parameters.map((param, idx) => ( +
0 ? 'border-t border-gray-200' : ''}`} + > +
+ + {param.name} + + + + {param.type} + + + {param.required && ( + + required + + )} +
+ +

+ {param.description} +

+
+ ))} +
+
+ )} + + {/* Returns */} +
+
+ + Returns +
+ +
+ + {selectedMethod.returns.type} + + +

+ {selectedMethod.returns.description} +

+ +
+ +
+
+
+ + {/* Code example */} +
+
+ + Example +
+ + +
+ + {/* Tips */} + {selectedMethod.tips && selectedMethod.tips.length > 0 && ( +
+
+ + Pro Tips +
+ +
    + {selectedMethod.tips.map((tip, idx) => ( +
  • + + {tip} +
  • + ))} +
+
+ )} + + {/* Template Manager for initiateTemplateIssue */} + {selectedMethodId === 'initiateTemplateIssue' && ( +
+
+
+
+ + Template Builder +
+ +

+ Create and manage credential templates for your embedded app +

+
+ + +
+ + {showTemplateManager && ( + <> + {/* Step 1: Integration Selection */} +
+
+ + +
+ + {isLoadingIntegrations ? ( +
+ + Loading integrations... +
+ ) : integrations && integrations.length > 0 ? ( +
+ {integrations.map((integration) => ( + + ))} +
+ ) : ( +

No integrations found

+ )} + + {/* Create new integration */} + {isCreatingIntegration ? ( +
+ setNewIntegrationName(e.target.value)} + placeholder="Integration name" + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500" + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateIntegration(); + if (e.key === 'Escape') setIsCreatingIntegration(false); + }} + autoFocus + /> + + +
+ ) : ( + + )} +
+ + {/* Step 2: App Listing Selection */} + {selectedIntegration && ( +
+
+ + +
+ + {isLoadingListings ? ( +
+ + Loading app listings... +
+ ) : listings && listings.length > 0 ? ( +
+ {listings.map((listing) => ( + + ))} +
+ ) : ( +

No app listings for this integration

+ )} + + {/* Create new listing */} + {isCreatingListing ? ( +
+ setNewListingName(e.target.value)} + placeholder="App name" + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500" + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateListing(); + if (e.key === 'Escape') setIsCreatingListing(false); + }} + autoFocus + /> + + +
+ ) : ( + + )} +
+ )} + + {/* Selected Status */} + {selectedIntegration && selectedListing && ( +
+

+ + Managing templates for {selectedListing.display_name} +

+
+ )} + + {/* Template Manager */} + + + )} +
+ )} +
+
+ + {/* Success state / Next steps */} +
+
+
+ +
+ +
+

Ready to build!

+ +

+ You now have everything you need to build a powerful embedded LearnCard app. + Check out the full documentation for advanced features and best practices. +

+ + +
+
+
+ + {/* Navigation */} +
+ +
+
+ ); +}; + +// Step 2: Feature Setup (dynamic per-feature setup) +const FeatureSetupStep: React.FC<{ + onComplete: () => void; + onBack: () => void; + selectedFeatures: string[]; + currentFeatureIndex: number; + setCurrentFeatureIndex: (index: number) => void; + featureSetupState: Record>; + setFeatureSetupState: (state: Record>) => void; + selectedListing: AppStoreListing | null; +}> = ({ onComplete, onBack, selectedFeatures, currentFeatureIndex, setCurrentFeatureIndex, featureSetupState, setFeatureSetupState, selectedListing }) => { + // Get features that require setup + const featuresNeedingSetup = selectedFeatures + .map(id => FEATURES.find(f => f.id === id)) + .filter((f): f is Feature => f !== undefined && f.requiresSetup); + + // If no features need setup, skip to complete + useEffect(() => { + if (featuresNeedingSetup.length === 0) { + onComplete(); + } + }, [featuresNeedingSetup.length, onComplete]); + + if (featuresNeedingSetup.length === 0) { + return null; + } + + const currentFeature = featuresNeedingSetup[currentFeatureIndex]; + const isLastFeature = currentFeatureIndex === featuresNeedingSetup.length - 1; + + const handleFeatureComplete = () => { + if (isLastFeature) { + onComplete(); + } else { + setCurrentFeatureIndex(currentFeatureIndex + 1); + } + }; + + const handleFeatureBack = () => { + if (currentFeatureIndex === 0) { + onBack(); + } else { + setCurrentFeatureIndex(currentFeatureIndex - 1); + } + }; + + // Render feature-specific setup + const renderFeatureSetup = () => { + if (!currentFeature) return null; + + switch (currentFeature.id) { + case 'issue-credentials': + return ( + + ); + + case 'peer-badges': + return ( + + ); + + case 'request-credentials': + return ( + + ); + + case 'request-data-consent': + return ( + + ); + + case 'launch-feature': + return ( + + ); + + default: + return null; + } + }; + + return ( +
+ {/* Feature progress */} + {featuresNeedingSetup.length > 1 && ( +
+ {featuresNeedingSetup.map((feature, index) => ( + +
+ {index < currentFeatureIndex ? ( + + ) : ( + feature.icon + )} + {feature.title} +
+ + {index < featuresNeedingSetup.length - 1 && ( + + )} +
+ ))} +
+ )} + + {renderFeatureSetup()} +
+ ); +}; + +// Issue Credentials Setup - Two modes: Prompt to Claim vs Sync to Wallet +type IssueMode = 'prompt-claim' | 'sync-wallet'; + +const IssueCredentialsSetup: React.FC<{ + onComplete: () => void; + onBack: () => void; + isLastFeature: boolean; + selectedListing: AppStoreListing | null; + featureSetupState: Record>; + setFeatureSetupState: (state: Record>) => void; +}> = ({ onComplete, onBack, isLastFeature, selectedListing, featureSetupState, setFeatureSetupState }) => { + const { initWallet } = useWallet(); + const { presentToast } = useToast(); + + // Get saved mode from feature state or default + const savedState = featureSetupState['issue-credentials'] || {}; + const [mode, setMode] = useState((savedState.mode as IssueMode) || 'prompt-claim'); + + // Prompt to Claim state + const [showCredentialBuilder, setShowCredentialBuilder] = useState(false); + const [credential, setCredential] = useState | null>( + (savedState.credential as Record) || null + ); + const [copiedCode, setCopiedCode] = useState(null); + + // Sync to Wallet state + const [signingAuthorityLoading, setSigningAuthorityLoading] = useState(false); + const [signingAuthorityFetched, setSigningAuthorityFetched] = useState(false); + const [signingAuthorityCreating, setSigningAuthorityCreating] = useState(false); + const [primarySA, setPrimarySA] = useState<{ name: string; endpoint: string } | null>(null); + const [contractUri, setContractUri] = useState((savedState.contractUri as string) || ''); + + // Save state when it changes + useEffect(() => { + setFeatureSetupState({ + ...featureSetupState, + 'issue-credentials': { + mode, + credential, + contractUri, + }, + }); + }, [mode, credential, contractUri]); + + // Fetch signing authority when switching to sync-wallet mode + useEffect(() => { + if (mode !== 'sync-wallet') return; + if (signingAuthorityFetched) return; + + const fetchSigningAuthority = async () => { + try { + setSigningAuthorityLoading(true); + const wallet = await initWallet(); + const primary = await wallet.invoke.getPrimaryRegisteredSigningAuthority(); + + if (primary?.relationship) { + setPrimarySA({ + name: primary.relationship.name, + endpoint: primary.signingAuthority?.endpoint ?? '', + }); + } else { + setPrimarySA(null); + } + } catch (err) { + console.error('Failed to fetch signing authority:', err); + setPrimarySA(null); + } finally { + setSigningAuthorityLoading(false); + setSigningAuthorityFetched(true); + } + }; + + fetchSigningAuthority(); + }, [mode, signingAuthorityFetched, initWallet]); + + const createSigningAuthority = async () => { + try { + setSigningAuthorityCreating(true); + const wallet = await initWallet(); + + const authority = await wallet.invoke.createSigningAuthority('default-sa'); + + if (!authority) { + throw new Error('Failed to create signing authority'); + } + + await wallet.invoke.registerSigningAuthority( + authority.endpoint!, + authority.name, + authority.did! + ); + + await wallet.invoke.setPrimaryRegisteredSigningAuthority( + authority.endpoint!, + authority.name + ); + + setPrimarySA({ + name: authority.name, + endpoint: authority.endpoint!, + }); + + presentToast('Signing authority created!', { hasDismissButton: true }); + } catch (err) { + console.error('Failed to create signing authority:', err); + presentToast('Failed to create signing authority', { type: ToastTypeEnum.Error, hasDismissButton: true }); + } finally { + setSigningAuthorityCreating(false); + } + }; + + const handleCopy = async (code: string, id: string) => { + await navigator.clipboard.writeText(code); + setCopiedCode(id); + setTimeout(() => setCopiedCode(null), 2000); + }; + + const handleSaveCredential = (credentialData: Record) => { + setCredential(credentialData); + setShowCredentialBuilder(false); + presentToast('Credential template saved!', { hasDismissButton: true }); + }; + + // Code snippets + const promptClaimCode = `// 1. Get the user's identity +const identity = await learnCard.requestIdentity(); +const recipientDid = identity.did; + +// 2. Build the credential with recipient's DID +const credential = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json" + ], + "type": ["VerifiableCredential", "OpenBadgeCredential"], + "name": "${credential ? (credential as Record).name || 'Your Credential' : 'Your Credential'}", + "issuer": { + "id": "YOUR_ISSUER_DID", // Your organization's DID + "name": "Your Organization" + }, + "credentialSubject": { + "id": recipientDid, // Inject the user's DID here + "type": ["AchievementSubject"], + "achievement": { + "type": ["Achievement"], + "name": "${credential ? (credential as Record).name || 'Achievement Name' : 'Achievement Name'}", + "description": "Description of the achievement" + } + } +}; + +// 3. Issue the credential server-side (with your API key) +// POST to your backend, which calls: +// const wallet = await initLearnCard({ seed: YOUR_SEED }); +// const vc = await wallet.invoke.issueCredential(credential); + +// 4. Prompt user to claim the issued credential +const result = await learnCard.sendCredential({ credential: issuedVC }); + +if (result.success) { + console.log('Credential claimed!'); +}`; + + const syncWalletCode = `// 1. Request consent for writing credentials +const consentResult = await learnCard.requestConsent({ + contractUri: '${contractUri || 'YOUR_CONTRACT_URI'}', +}); + +if (!consentResult.accepted) { + console.log('User declined consent'); + return; +} + +// 2. Get the user's identity +const identity = await learnCard.requestIdentity(); +const recipientDid = identity.did; + +// 3. Send credentials using the send() method +// This uses your consent flow contract for seamless delivery +const result = await learnCard.invoke.send({ + type: 'boost', + recipient: recipientDid, + templateUri: 'YOUR_BOOST_TEMPLATE_URI', // From Template Manager below + contractUri: '${contractUri || 'YOUR_CONTRACT_URI'}', +}); + +console.log('Credential synced:', result);`; + + return ( +
+
+

Issue Credentials

+ +

+ Choose how you want to deliver credentials to users in your embedded app. +

+
+ + {/* Mode Toggle */} +
+ + + +
+ + {/* Prompt to Claim Mode */} + {mode === 'prompt-claim' && ( +
+ {/* Step 1: Build Credential */} +
+
+
+ 1 +
+

Build Your Credential

+
+ +
+ {credential ? ( +
+
+
+ + + {(credential as Record).name as string || 'Credential Ready'} + +
+ + +
+
+ ) : ( + + )} +
+
+ + {/* Step 2: Integration Code */} +
+
+
+ 2 +
+

Integration Code

+
+ +
+ + +
+

+ Important: Step 3 (issuing) must happen on your server with your API key to sign the credential properly. +

+
+
+
+
+ )} + + {/* Sync to Wallet Mode */} + {mode === 'sync-wallet' && ( +
+ {/* Step 1: Signing Authority */} +
+
+
+ 1 +
+

Signing Authority

+
+ +
+ {(signingAuthorityLoading || !signingAuthorityFetched) ? ( +
+ + Checking signing authority... +
+ ) : primarySA ? ( +
+
+ +
+

Signing authority ready

+

Using: {primarySA.name}

+
+
+
+ ) : ( +
+
+

+ A signing authority is needed to cryptographically sign credentials. +

+ + +
+
+ )} +
+
+ + {/* Step 2: Consent Flow Contract */} +
+
+
+ 2 +
+

Consent Flow Contract

+
+ +
+

+ Create a consent contract that requests 'write' permission to sync credentials to the user's wallet. +

+ + + + {contractUri && ( +
+
+ + Contract selected +
+
+ )} +
+
+ + {/* Step 3: Credential Templates */} +
+
+
+ 3 +
+

Credential Templates (Boosts)

+
+ +
+ {selectedListing ? ( + + ) : ( +
+

+ Select an app in Step 1 to create credential templates. +

+
+ )} +
+
+ + {/* Step 4: Integration Code */} +
+
+
+ 4 +
+

Integration Code

+
+ +
+ +
+
+
+ )} + + {/* Navigation */} +
+ + + +
+ + {/* Credential Builder Modal */} + setShowCredentialBuilder(false)} + onSave={handleSaveCredential} + initialData={credential as Record | undefined} + /> +
+ ); +}; + +// Request Credentials Setup - Two modes: Query vs Specific +type RequestMode = 'query' | 'specific'; + +const RequestCredentialsSetup: React.FC<{ + onComplete: () => void; + onBack: () => void; + isLastFeature: boolean; + featureSetupState: Record>; + setFeatureSetupState: (state: Record>) => void; +}> = ({ onComplete, onBack, isLastFeature, featureSetupState, setFeatureSetupState }) => { + // Get saved state + const savedState = featureSetupState['request-credentials'] || {}; + const [mode, setMode] = useState((savedState.mode as RequestMode) || 'query'); + const [copiedCode, setCopiedCode] = useState(null); + + // Query mode state + const [queryTitle, setQueryTitle] = useState((savedState.queryTitle as string) || ''); + const [queryReason, setQueryReason] = useState((savedState.queryReason as string) || ''); + + // Save state when it changes + useEffect(() => { + setFeatureSetupState({ + ...featureSetupState, + 'request-credentials': { + mode, + queryTitle, + queryReason, + }, + }); + }, [mode, queryTitle, queryReason]); + + const handleCopy = async (code: string, id: string) => { + await navigator.clipboard.writeText(code); + setCopiedCode(id); + setTimeout(() => setCopiedCode(null), 2000); + }; + + // Code for Query mode + const queryCode = `// Search for credentials matching your criteria +const response = await learnCard.askCredentialSearch({ + query: [ + { + type: 'QueryByTitle', + credentialQuery: { + reason: "${queryReason || 'Please share your credential for verification'}", + title: "${queryTitle || 'Certificate'}" + } + } + ], + challenge: \`challenge-\${Date.now()}-\${Math.random().toString(36).substring(2, 9)}\`, + domain: window.location.hostname +}); + +if (response?.verifiablePresentation) { + // User shared credentials in a signed Verifiable Presentation + const vp = response.verifiablePresentation; + const credentials = vp.verifiableCredential || []; + + console.log(\`User shared \${credentials.length} credential(s)\`); + + // Process each credential + for (const credential of credentials) { + console.log('Credential:', credential.name); + // Verify and use the credential + } +} else { + console.log('User declined to share credentials'); +}`; + + // Code for Specific mode + const specificCode = `// Request a specific credential by its ID +// You would typically store the credential ID from a previous interaction +const credentialId = 'urn:credential:abc123'; // The ID you stored earlier + +try { + const response = await learnCard.askCredentialSpecific(credentialId); + + if (response.credential) { + console.log('Received credential:', response.credential); + + // The credential is now available for verification + const credType = response.credential.type?.join(', ') || 'Unknown'; + console.log('Credential type:', credType); + } else { + console.log('Credential not returned'); + } +} catch (error) { + if (error.code === 'CREDENTIAL_NOT_FOUND') { + console.log('Credential not found in user wallet'); + } else if (error.code === 'USER_REJECTED') { + console.log('User declined to share'); + } else { + console.error('Error:', error.message); + } +}`; + + return ( +
+
+

Request Credentials

+ +

+ Choose how you want to request credentials from users in your app. +

+
+ + {/* Mode Toggle */} +
+ + + +
+ + {/* Query Mode */} + {mode === 'query' && ( +
+ {/* Step 1: Configure Search */} +
+
+
+ 1 +
+

Configure Your Search

+
+ +
+
+ + + setQueryTitle(e.target.value)} + placeholder="e.g., Certificate, Badge, Diploma" + className="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500" + /> + +

+ This searches credential titles for matches +

+
+ +
+ + + setQueryReason(e.target.value)} + placeholder="e.g., To verify your qualifications for this role" + className="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500" + /> + +

+ A clear reason builds trust and improves sharing rates +

+
+
+
+ + {/* Step 2: Integration Code */} +
+
+
+ 2 +
+

Integration Code

+
+ +
+ +
+
+ + {/* How it works */} +
+

How it works

+ +
    +
  1. 1. Your app requests credentials matching the title
  2. +
  3. 2. User sees which credentials match and selects which to share
  4. +
  5. 3. You receive a signed Verifiable Presentation with the credentials
  6. +
  7. 4. Verify the presentation to confirm authenticity
  8. +
+
+
+ )} + + {/* Specific Mode */} + {mode === 'specific' && ( +
+ {/* Explanation */} +
+
+
+ 1 +
+

How to Use

+
+ +
+

+ Request a specific credential when you already know its ID from a previous interaction: +

+ +
    +
  • + + Re-verification: Ask for the same credential again +
  • +
  • + + Saved reference: You stored the ID when they first shared it +
  • +
  • + + Deep linking: They clicked a link with a specific credential +
  • +
+
+
+ + {/* Integration Code */} +
+
+
+ 2 +
+

Integration Code

+
+ +
+ +
+
+ + {/* Tips */} +
+

Tips

+ +
    +
  • • Store credential IDs securely when users first share them
  • +
  • • Handle the case where the user no longer has the credential
  • +
  • • Provide a fallback to search if the specific credential isn't available
  • +
+
+
+ )} + + {/* Navigation */} +
+ + + +
+
+ ); +}; + +// Request Data Consent Setup +const RequestDataConsentSetup: React.FC<{ + onComplete: () => void; + onBack: () => void; + isLastFeature: boolean; + featureSetupState: Record>; + setFeatureSetupState: React.Dispatch>>>; +}> = ({ onComplete, onBack, isLastFeature, featureSetupState, setFeatureSetupState }) => { + // Get saved state + const savedState = featureSetupState['request-data-consent'] || {}; + const [contractUri, setContractUri] = useState((savedState.contractUri as string) || ''); + + // Save state when contractUri changes + useEffect(() => { + setFeatureSetupState(prev => ({ + ...prev, + 'request-data-consent': { ...prev['request-data-consent'], contractUri } + })); + }, [contractUri, setFeatureSetupState]); + + // Client-side: Request consent from user + const clientCode = `// In your embedded app (client-side) +// Request consent from the user +const result = await learnCard.requestConsent({ + contractUri: '${contractUri || 'urn:lc:contract:your-contract-uri'}', +}); + +if (result.granted) { + console.log('User granted consent!'); + + // Send the consent confirmation to your server + await fetch('/api/consent-granted', { + method: 'POST', + body: JSON.stringify({ + userId: result.userId, + contractUri: '${contractUri || 'urn:lc:contract:your-contract-uri'}' + }) + }); +} else { + console.log('User declined consent'); +}`; + + // Server-side: Read and write data using consent + const serverCode = `// On your server (Node.js) +import { initLearnCard } from '@learncard/init'; + +// Initialize with your API key (recommended) or seed phrase +const learnCard = await initLearnCard({ + network: true, + apiKey: process.env.LEARNCARD_API_KEY, // Your API key + // Or use a seed: seed: process.env.LEARNCARD_SEED +}); + +const contractUri = '${contractUri || 'urn:lc:contract:your-contract-uri'}'; +const recipientProfileId = 'user-profile-id'; + +// READING: Get credentials the user has shared via consent +const credentials = await learnCard.invoke.getCredentialsForContract( + contractUri, + { limit: 50 } +); +console.log('User shared credentials:', credentials.records); + +// WRITING: Send a credential to the user via consent +const result = await learnCard.invoke.send({ + type: 'boost', + recipient: recipientProfileId, + templateUri: 'urn:lc:boost:your-template-uri', + contractUri: contractUri, // Routes via consent terms +}); +console.log('Credential sent:', result);`; + + return ( +
+
+

Request Data Consent

+ +

+ Ask users for permission to access specific data fields or write data back to their profile via a ConsentFlow contract. +

+
+ + {/* Step 1: Select Consent Contract */} +
+
+
+ 1 +
+ +

Select or Create a Consent Contract

+
+ +
+ +
+
+ + {/* Step 2: Client-side Code */} +
+
+
+ 2 +
+ +

Request Consent (Client-Side)

+
+ +
+

+ Use the Partner SDK to prompt the user for consent in your embedded app. +

+ + +
+
+ + {/* Step 3: Server-side Code */} +
+
+
+ 3 +
+ +

Read & Write Data (Server-Side)

+
+ +
+

+ On your server, initialize with your API key to read shared credentials and send new ones. +

+ + +
+
+ + {/* How it works */} +
+

How ConsentFlow Works

+ +
    +
  1. 1. You define a contract specifying what data you need access to
  2. +
  3. 2. User reviews and approves (or declines) the request
  4. +
  5. 3. You receive a consent ID for future data operations
  6. +
  7. 4. Use the consent ID to read/write data per contract terms
  8. +
+
+ + {/* Navigation */} +
+ + + +
+
+ ); +}; + +// Launch Feature Setup - Comprehensive Feature Builder +interface LaunchableFeature { + id: string; + path: string; + title: string; + description: string; + icon: React.ReactNode; + params?: { name: string; description: string; placeholder: string }[]; +} + +interface FeatureCategory { + id: string; + title: string; + icon: React.ReactNode; + color: string; + features: LaunchableFeature[]; +} + +const LAUNCHABLE_FEATURES: FeatureCategory[] = [ + { + id: 'core', + title: 'Core Navigation', + icon: , + color: 'blue', + features: [ + { id: 'passport', path: '/passport', title: 'Wallet / Passport', description: 'Main credential wallet view', icon: }, + { id: 'boost', path: '/boost', title: 'Boost Manager', description: 'Badge/boost management', icon: }, + { id: 'launchpad', path: '/launchpad', title: 'App Launchpad', description: 'App discovery hub', icon: }, + ], + }, + { + id: 'profile', + title: 'Profile & Identity', + icon: , + color: 'violet', + features: [ + { id: 'ids', path: '/ids', title: 'IDs & DIDs', description: 'User identity management', icon: }, + { id: 'connect', path: '/connect', title: 'Connect', description: 'Connect with others', icon: }, + { + id: 'connect-profile', + path: '/connect/:profileId', + title: 'Connect with User', + description: 'Connect with a specific user', + icon: , + params: [{ name: 'profileId', description: 'Profile ID to connect with', placeholder: 'user-profile-id' }] + }, + { id: 'contacts', path: '/contacts', title: 'Address Book', description: 'All contacts', icon: }, + { id: 'contacts-search', path: '/contacts/search', title: 'Search Contacts', description: 'Search for contacts', icon: }, + ], + }, + { + id: 'credentials', + title: 'Credentials & Achievements', + icon: , + color: 'amber', + features: [ + { id: 'achievements', path: '/achievements', title: 'Achievements', description: 'View achievements', icon: }, + { id: 'accomplishments', path: '/accomplishments', title: 'Accomplishments', description: 'View accomplishments', icon: }, + { id: 'skills', path: '/skills', title: 'Skills', description: 'Skills inventory', icon: }, + { id: 'learninghistory', path: '/learninghistory', title: 'Learning History', description: 'Educational timeline', icon: }, + { id: 'workhistory', path: '/workhistory', title: 'Work History', description: 'Employment records', icon: }, + { id: 'memberships', path: '/memberships', title: 'Memberships', description: 'Organization memberships', icon: }, + { + id: 'claim-boost', + path: '/claim/boost', + title: 'Claim Boost', + description: 'Claim a boost by URI', + icon: , + params: [{ name: 'uri', description: 'Boost URI to claim', placeholder: 'urn:lc:boost:abc123' }] + }, + ], + }, + { + id: 'ai', + title: 'AI Features', + icon: , + color: 'emerald', + features: [ + { id: 'chats', path: '/chats', title: 'AI Chat', description: 'Open AI chat interface', icon: }, + { id: 'ai-insights', path: '/ai/insights', title: 'AI Insights', description: 'AI-powered insights', icon: }, + { id: 'ai-topics', path: '/ai/topics', title: 'AI Topics', description: 'Browse AI session topics', icon: }, + { id: 'ai-sessions', path: '/ai/sessions', title: 'AI Sessions', description: 'View AI session history', icon: }, + ], + }, + { + id: 'apps', + title: 'Apps & Launchpad', + icon: , + color: 'cyan', + features: [ + { id: 'launchpad-main', path: '/launchpad', title: 'App Launchpad', description: 'Browse all apps', icon: }, + { + id: 'app-embed', + path: '/apps/:appId', + title: 'Open App', + description: 'Launch an embedded app fullscreen', + icon: , + params: [{ name: 'appId', description: 'App ID to launch', placeholder: 'my-app-id' }] + }, + { + id: 'app-listing', + path: '/app/:listingId', + title: 'App Details', + description: 'View app listing page', + icon: , + params: [{ name: 'listingId', description: 'Listing ID', placeholder: 'listing-123' }] + }, + ], + }, + { + id: 'notifications', + title: 'Notifications', + icon: , + color: 'rose', + features: [ + { id: 'notifications', path: '/notifications', title: 'Notifications', description: 'View all notifications', icon: }, + ], + }, + { + id: 'admin', + title: 'Admin Tools', + icon: , + color: 'gray', + features: [ + { id: 'admin-tools', path: '/admin-tools', title: 'Admin Dashboard', description: 'Admin tools home', icon: }, + { id: 'managed-boosts', path: '/admin-tools/view-managed-boosts', title: 'Managed Boosts', description: 'View all managed boosts', icon: }, + { id: 'bulk-import', path: '/admin-tools/bulk-import', title: 'Bulk Import', description: 'Bulk boost import', icon: }, + { id: 'service-profiles', path: '/admin-tools/service-profiles', title: 'Service Profiles', description: 'Manage service profiles', icon: }, + { id: 'manage-contracts', path: '/admin-tools/manage-contracts', title: 'Consent Contracts', description: 'Manage consent contracts', icon: }, + { id: 'signing-authorities', path: '/admin-tools/signing-authorities', title: 'Signing Authorities', description: 'Manage signing authorities', icon: }, + { id: 'api-tokens', path: '/admin-tools/api-tokens', title: 'API Tokens', description: 'Manage API tokens', icon: }, + ], + }, + { + id: 'family', + title: 'Family', + icon: , + color: 'pink', + features: [ + { id: 'families', path: '/families', title: 'Family Management', description: 'Manage family members', icon: }, + ], + }, +]; + +const LaunchFeatureSetup: React.FC<{ + onComplete: () => void; + onBack: () => void; + isLastFeature: boolean; + featureSetupState: Record>; + setFeatureSetupState: React.Dispatch>>>; +}> = ({ onComplete, onBack, isLastFeature, featureSetupState, setFeatureSetupState }) => { + // Get saved state + const savedState = featureSetupState['launch-feature'] || {}; + const [selectedFeatureIds, setSelectedFeatureIds] = useState( + (savedState.selectedFeatureIds as string[]) || [] + ); + const [expandedCategories, setExpandedCategories] = useState( + (savedState.expandedCategories as string[]) || ['core'] + ); + const [paramValues, setParamValues] = useState>>( + (savedState.paramValues as Record>) || {} + ); + + // Get all selected features + const selectedFeatures = LAUNCHABLE_FEATURES + .flatMap(cat => cat.features) + .filter(f => selectedFeatureIds.includes(f.id)); + + // Toggle feature selection + const toggleFeature = (featureId: string) => { + setSelectedFeatureIds(prev => + prev.includes(featureId) + ? prev.filter(id => id !== featureId) + : [...prev, featureId] + ); + }; + + // Toggle category expansion + const toggleCategory = (categoryId: string) => { + setExpandedCategories(prev => + prev.includes(categoryId) + ? prev.filter(id => id !== categoryId) + : [...prev, categoryId] + ); + }; + + // Save state when values change + useEffect(() => { + setFeatureSetupState(prev => ({ + ...prev, + 'launch-feature': { + ...prev['launch-feature'], + selectedFeatureIds, + expandedCategories, + paramValues + } + })); + }, [selectedFeatureIds, expandedCategories, paramValues, setFeatureSetupState]); + + // Generate code for all selected features + const generateCode = () => { + if (selectedFeatures.length === 0) { + return `// Select features above to see example code`; + } + + const codeBlocks = selectedFeatures.map(feature => { + let path = feature.path; + const featureParams = paramValues[feature.id] || {}; + + // Replace path params + if (feature.params) { + feature.params.forEach(param => { + const value = featureParams[param.name] || param.placeholder; + path = path.replace(`:${param.name}`, value); + }); + } + + return `// Launch ${feature.title} +await learnCard.launchFeature('${path}');`; + }); + + return codeBlocks.join('\n\n'); + }; + + const getCategoryColorClasses = (color: string, isExpanded: boolean) => { + const colors: Record = { + blue: { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-800', icon: 'text-blue-600' }, + violet: { bg: 'bg-violet-50', border: 'border-violet-200', text: 'text-violet-800', icon: 'text-violet-600' }, + amber: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-800', icon: 'text-amber-600' }, + emerald: { bg: 'bg-emerald-50', border: 'border-emerald-200', text: 'text-emerald-800', icon: 'text-emerald-600' }, + cyan: { bg: 'bg-cyan-50', border: 'border-cyan-200', text: 'text-cyan-800', icon: 'text-cyan-600' }, + rose: { bg: 'bg-rose-50', border: 'border-rose-200', text: 'text-rose-800', icon: 'text-rose-600' }, + gray: { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-800', icon: 'text-gray-600' }, + pink: { bg: 'bg-pink-50', border: 'border-pink-200', text: 'text-pink-800', icon: 'text-pink-600' }, + }; + + return colors[color] || colors.gray; + }; + + return ( +
+
+

Launch Native Features

+ +

+ Navigate users to any LearnCard screen directly from your app. Select a category and feature to configure. +

+
+ + {/* Step 1: Category & Feature Selection */} +
+
+
+
+ 1 +
+ +

Select Features

+
+ + {selectedFeatureIds.length > 0 && ( + + {selectedFeatureIds.length} selected + + )} +
+ +
+ {LAUNCHABLE_FEATURES.map(category => { + const isExpanded = expandedCategories.includes(category.id); + const colors = getCategoryColorClasses(category.color, isExpanded); + const selectedCount = category.features.filter(f => selectedFeatureIds.includes(f.id)).length; + + return ( +
+ + + {isExpanded && ( +
+
+ {category.features.map(feature => { + const isSelected = selectedFeatureIds.includes(feature.id); + + return ( + + ); + })} +
+
+ )} +
+ ); + })} +
+
+ + {/* Step 2: Configure Parameters for selected features with params */} + {selectedFeatures.some(f => f.params && f.params.length > 0) && ( +
+
+
+ 2 +
+ +

Configure Parameters

+
+ +
+ {selectedFeatures.filter(f => f.params && f.params.length > 0).map(feature => ( +
+
+ {feature.icon} + {feature.title} +
+ + {feature.params?.map(param => ( +
+ + + setParamValues(prev => ({ + ...prev, + [feature.id]: { + ...prev[feature.id], + [param.name]: e.target.value + } + }))} + placeholder={param.placeholder} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +
+ ))} +
+ ))} +
+
+ )} + + {/* Step 3: Integration Code */} +
+
+
+ {selectedFeatures.some(f => f.params?.length) ? '3' : '2'} +
+ +

Integration Code

+
+ +
+ {selectedFeatures.length > 0 && ( +
+ {selectedFeatures.map(feature => ( + + {feature.icon} + {feature.title} + + ))} +
+ )} + + +
+
+ + {/* Tips */} +
+

Tips

+ +
    +
  • launchFeature navigates users to any LearnCard screen
  • +
  • • Parameters are passed as URL params or path segments
  • +
  • • Admin tools are only accessible to users with admin permissions
  • +
  • • Results indicate success/failure of the navigation
  • +
+
+ + {/* Navigation */} +
+ + + +
+
+ ); +}; + +// Peer-to-Peer Badges Setup (for initiateTemplateIssuance) +const PeerBadgesSetup: React.FC<{ + onComplete: () => void; + onBack: () => void; + isLastFeature: boolean; + selectedListing: AppStoreListing | null; +}> = ({ onComplete, onBack, isLastFeature, selectedListing }) => { + return ( +
+
+

Set Up Peer-to-Peer Badges

+ +

+ Create badge templates that users can send to each other using initiateTemplateIssuance. +

+
+ + {/* Show selected app */} + {selectedListing && ( +
+
+ +
+

Creating templates for: {selectedListing.display_name}

+

You selected this app in Step 1

+
+
+
+ )} + + {/* How it works */} +
+

How Peer-to-Peer Badges Work

+ +
    +
  1. + 1. + You create badge templates below (e.g., "Thank You", "Great Job", "Team Player") +
  2. +
  3. + 2. + In your app, call initiateTemplateIssuance with a template URI +
  4. +
  5. + 3. + Users pick a recipient and send the badge — you control the UX! +
  6. +
+
+ + {/* Template Manager */} + {selectedListing ? ( + + ) : ( +
+ +

+ Please go back to Step 1 and select an app listing first. +

+
+ )} + + {/* Navigation */} +
+ + + +
+
+ ); +}; + +// Step 3: Your App (summary with code) +const YourAppStep: React.FC<{ + onBack: () => void; + selectedFeatures: string[]; + selectedListing: AppStoreListing | null; + featureSetupState: Record>; +}> = ({ onBack, selectedFeatures, selectedListing, featureSetupState }) => { + const { useUpdateListing } = useDeveloperPortal(); + const updateMutation = useUpdateListing(); + const { presentToast } = useToast(); + const { newModal } = useModal(); + + const [copiedCode, setCopiedCode] = useState(false); + const [showConfigEditor, setShowConfigEditor] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [showConfigMismatchPrompt, setShowConfigMismatchPrompt] = useState(false); + const [hasCheckedConfig, setHasCheckedConfig] = useState(false); + + // Extract configured values from feature setup state + const issueCredentialsState = featureSetupState['issue-credentials'] || {}; + const requestCredentialsState = featureSetupState['request-credentials'] || {}; + const requestDataConsentState = featureSetupState['request-data-consent'] || {}; + const launchFeatureState = featureSetupState['launch-feature'] || {}; + + // Parse existing launch config from listing + const existingConfig: LaunchConfig = (() => { + try { + return selectedListing?.launch_config_json + ? JSON.parse(selectedListing.launch_config_json) + : {}; + } catch { + return {}; + } + })(); + + // App config state + const [embedUrl, setEmbedUrl] = useState(existingConfig.url || ''); + const [selectedPermissions, setSelectedPermissions] = useState( + existingConfig.permissions || [] + ); + const [contractUri, setContractUri] = useState(existingConfig.contractUri || ''); + + // Compute required permissions based on selected features + const computeRequiredPermissions = useCallback((): AppPermission[] => { + const permissions = new Set(); + + // Always need identity + permissions.add('request_identity'); + + if (selectedFeatures.includes('issue-credentials')) { + permissions.add('send_credential'); + } + + if (selectedFeatures.includes('peer-badges')) { + permissions.add('template_issuance'); + } + + if (selectedFeatures.includes('request-credentials')) { + const mode = requestCredentialsState.mode as string || 'query'; + if (mode === 'query') { + permissions.add('credential_search'); + } else { + permissions.add('credential_by_id'); + } + } + + if (selectedFeatures.includes('request-data-consent')) { + permissions.add('request_consent'); + } + + if (selectedFeatures.includes('launch-feature')) { + permissions.add('launch_feature'); + } + + return Array.from(permissions); + }, [selectedFeatures, requestCredentialsState.mode]); + + // Auto-compute permissions when features change + useEffect(() => { + const required = computeRequiredPermissions(); + setSelectedPermissions(prev => { + // Merge required with existing, keeping any extras + const merged = new Set([...prev, ...required]); + return Array.from(merged); + }); + }, [computeRequiredPermissions]); + + // Auto-set contract URI if using request-data-consent + useEffect(() => { + if (selectedFeatures.includes('request-data-consent')) { + const consentContractUri = requestDataConsentState.contractUri as string; + if (consentContractUri && !contractUri) { + setContractUri(consentContractUri); + } + } + }, [selectedFeatures, requestDataConsentState.contractUri, contractUri]); + + // Compute the new config based on user selections + const computeNewConfig = useCallback((): LaunchConfig => { + const requiredPermissions = computeRequiredPermissions(); + const consentContractUri = selectedFeatures.includes('request-data-consent') + ? (requestDataConsentState.contractUri as string) || '' + : ''; + + return { + url: existingConfig.url || '', // Keep existing URL + permissions: requiredPermissions, + contractUri: consentContractUri || undefined, + }; + }, [computeRequiredPermissions, selectedFeatures, requestDataConsentState.contractUri, existingConfig.url]); + + // Check for config mismatch on mount + useEffect(() => { + if (hasCheckedConfig || !selectedListing) return; + + const newConfig = computeNewConfig(); + const existingPermissions = existingConfig.permissions || []; + const existingContractUri = existingConfig.contractUri || ''; + + // Check if permissions are different + const permissionsDifferent = + newConfig.permissions?.length !== existingPermissions.length || + newConfig.permissions?.some(p => !existingPermissions.includes(p)) || + existingPermissions.some(p => !newConfig.permissions?.includes(p)); + + // Check if contract URI is different + const contractDifferent = (newConfig.contractUri || '') !== existingContractUri; + + if (permissionsDifferent || contractDifferent) { + setShowConfigMismatchPrompt(true); + } + + setHasCheckedConfig(true); + }, [hasCheckedConfig, selectedListing, computeNewConfig, existingConfig]); + + // Handle accepting the config update + const handleAcceptConfigUpdate = async () => { + if (!selectedListing) return; + + setIsSaving(true); + try { + const newConfig = computeNewConfig(); + // Preserve the existing URL if set, otherwise use the computed one + newConfig.url = embedUrl || existingConfig.url || ''; + + await updateMutation.mutateAsync({ + listingId: selectedListing.listing_id, + updates: { + launch_config_json: JSON.stringify(newConfig, null, 2), + }, + }); + + // Update local state to match + setSelectedPermissions(newConfig.permissions || []); + if (newConfig.contractUri) { + setContractUri(newConfig.contractUri); + } + + presentToast('App configuration updated!', { hasDismissButton: true }); + setShowConfigMismatchPrompt(false); + } catch (error) { + console.error('Failed to update config:', error); + presentToast('Failed to update configuration', { + type: ToastTypeEnum.Error, + hasDismissButton: true + }); + } finally { + setIsSaving(false); + } + }; + + // Save config to listing + const handleSaveConfig = async () => { + if (!selectedListing) return; + + setIsSaving(true); + try { + const newConfig: LaunchConfig = { + url: embedUrl, + permissions: selectedPermissions, + contractUri: contractUri || undefined, + }; + + await updateMutation.mutateAsync({ + listingId: selectedListing.listing_id, + updates: { + launch_config_json: JSON.stringify(newConfig, null, 2), + }, + }); + + presentToast('App configuration saved!', { hasDismissButton: true }); + setShowConfigEditor(false); + } catch (error) { + console.error('Failed to save config:', error); + presentToast('Failed to save configuration', { + type: ToastTypeEnum.Error, + hasDismissButton: true + }); + } finally { + setIsSaving(false); + } + }; + + // Toggle permission + const togglePermission = (permission: AppPermission) => { + setSelectedPermissions(prev => + prev.includes(permission) + ? prev.filter(p => p !== permission) + : [...prev, permission] + ); + }; + + // Create mock listing and open preview modal + const openPreviewModal = () => { + if (!selectedListing) return; + + // Build launch config with current values + const launchConfig: LaunchConfig = { + url: embedUrl, + permissions: selectedPermissions, + contractUri: contractUri || undefined, + }; + + const mockListing: ExtendedAppStoreListing = { + listing_id: selectedListing.listing_id, + display_name: selectedListing.display_name, + tagline: selectedListing.tagline || '', + full_description: selectedListing.full_description || '', + icon_url: selectedListing.icon_url || 'https://placehold.co/128x128/e2e8f0/64748b?text=Preview', + launch_type: 'EMBEDDED_IFRAME', + launch_config_json: JSON.stringify(launchConfig), + app_listing_status: 'DRAFT', + category: (selectedListing as ExtendedAppStoreListing).category, + promo_video_url: (selectedListing as ExtendedAppStoreListing).promo_video_url, + privacy_policy_url: (selectedListing as ExtendedAppStoreListing).privacy_policy_url, + terms_url: (selectedListing as ExtendedAppStoreListing).terms_url, + ios_app_store_id: (selectedListing as ExtendedAppStoreListing).ios_app_store_id, + android_app_store_id: (selectedListing as ExtendedAppStoreListing).android_app_store_id, + highlights: (selectedListing as ExtendedAppStoreListing).highlights, + screenshots: (selectedListing as ExtendedAppStoreListing).screenshots, + hero_background_color: (selectedListing as ExtendedAppStoreListing).hero_background_color, + }; + + newModal( + , + { hideButton: true }, + { desktop: ModalTypes.FullScreen, mobile: ModalTypes.FullScreen } + ); + }; + + // Generate comprehensive code based on selected features and their configuration + const generateCode = () => { + const sections: string[] = []; + + // =================== + // HEADER / METADATA + // =================== + sections.push(`/** + * LearnCard Embedded App Integration + * ================================== + * + * App: ${selectedListing?.display_name || 'Your App Name'} + * Generated: ${new Date().toISOString().split('T')[0]} + * + * Features configured: + * ${selectedFeatures.map(id => ` - ${FEATURES.find(f => f.id === id)?.title || id}`).join('\n * ')} + * + * Prerequisites: + * 1. Install the SDK: npm install @learncard/partner-connect + * 2. Your app must be served in an iframe within LearnCard + * 3. Configure CORS headers to allow iframe embedding + * + * Documentation: https://docs.learncard.com/sdks/partner-connect + */`); + + // =================== + // IMPORTS + // =================== + sections.push(` +// ============================================================ +// IMPORTS +// ============================================================ +import { createPartnerConnect } from '@learncard/partner-connect';`); + + // =================== + // SDK INITIALIZATION + // =================== + sections.push(` +// ============================================================ +// SDK INITIALIZATION +// ============================================================ +// Create the partner connection to communicate with LearnCard +const learnCard = createPartnerConnect({ + hostOrigin: 'https://learncard.app', // LearnCard app origin +});`); + + // =================== + // USER IDENTITY + // =================== + sections.push(` +// ============================================================ +// USER IDENTITY +// ============================================================ +// Request the user's identity - this is typically called on app load +// Returns the user's DID, profile info, and display name +async function getUserIdentity() { + try { + const identity = await learnCard.requestIdentity(); + + console.log('User DID:', identity.did); + console.log('Display Name:', identity.profile.displayName); + console.log('Profile ID:', identity.profile.profileId); + + return identity; + } catch (error) { + console.error('Failed to get user identity:', error); + throw error; + } +}`); + + // =================== + // ISSUE CREDENTIALS + // =================== + if (selectedFeatures.includes('issue-credentials')) { + const mode = issueCredentialsState.mode as string || 'prompt-claim'; + const credential = issueCredentialsState.credential as Record | null; + const credentialName = credential?.name as string || 'Your Credential'; + const contractUri = issueCredentialsState.contractUri as string || ''; + + if (mode === 'prompt-claim') { + sections.push(` +// ============================================================ +// ISSUE CREDENTIALS - Prompt to Claim +// ============================================================ +// Mode: Build a credential and prompt user to claim it +// +// How it works: +// 1. Get the user's DID to include in the credential +// 2. Build the credential with their DID as the subject +// 3. Issue the credential server-side (with your signing authority) +// 4. Send the issued VC to the user to claim + +async function issueCredentialToUser() { + // 1. Get the user's identity + const identity = await learnCard.requestIdentity(); + const recipientDid = identity.did; + + // 2. Build the credential with recipient's DID + const credential = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json" + ], + "type": ["VerifiableCredential", "OpenBadgeCredential"], + "name": "${credentialName}", + "issuer": { + "id": "YOUR_ISSUER_DID", // TODO: Replace with your organization's DID + "name": "Your Organization" + }, + "credentialSubject": { + "id": recipientDid, // Inject the user's DID here + "type": ["AchievementSubject"], + "achievement": { + "type": ["Achievement"], + "name": "${credentialName}", + "description": "Description of the achievement" + } + } + }; + + // 3. Issue the credential server-side (with your API key) + // POST to your backend, which calls: + // const wallet = await initLearnCard({ seed: YOUR_SEED }); + // const issuedVC = await wallet.invoke.issueCredential(credential); + const response = await fetch('/api/issue-credential', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential }) + }); + const { issuedVC } = await response.json(); + + // 4. Prompt user to claim the issued credential + const result = await learnCard.sendCredential({ credential: issuedVC }); + + if (result.success) { + console.log('Credential claimed!'); + } +}`); + } else { + // sync-wallet mode + sections.push(` +// ============================================================ +// ISSUE CREDENTIALS - Sync to Wallet (Server-Side) +// ============================================================ +// Mode: Silently sync credentials to user's wallet via consent +// +// How it works: +// 1. User grants consent via ConsentFlow contract +// 2. Your SERVER issues credentials using your signing authority +// 3. Credentials appear in user's wallet automatically +// +// IMPORTANT: This requires server-side code with your signing authority + +// --- CLIENT-SIDE: Request consent from user --- +async function requestUserConsent() { + const result = await learnCard.requestConsent({ + contractUri: '${contractUri || 'urn:lc:contract:YOUR_CONTRACT_URI'}', + }); + + if (result.granted) { + // Notify your server that consent was granted + await fetch('/api/consent-granted', { + method: 'POST', + body: JSON.stringify({ + userId: result.userId, + contractUri: '${contractUri || 'urn:lc:contract:YOUR_CONTRACT_URI'}' + }) + }); + return true; + } + return false; +} + +// --- SERVER-SIDE CODE (Node.js) --- +// This code runs on YOUR server, not in the embedded app +/* +import { initLearnCard } from '@learncard/init'; + +// Initialize LearnCard with your signing authority +const learnCard = await initLearnCard({ + network: true, + seed: process.env.LEARNCARD_SEED, // Your secure seed phrase +}); + +// Issue credential to user via consent contract +async function issueCredentialViaConsent(recipientProfileId: string) { + const result = await learnCard.invoke.send({ + type: 'boost', + recipient: recipientProfileId, + templateUri: 'urn:lc:boost:YOUR_TEMPLATE_URI', + contractUri: '${contractUri || 'urn:lc:contract:YOUR_CONTRACT_URI'}', + }); + + console.log('Credential issued:', result); + return result; +} +*/`); + } + } + + // =================== + // PEER BADGES + // =================== + if (selectedFeatures.includes('peer-badges')) { + sections.push(` +// ============================================================ +// PEER-TO-PEER BADGES +// ============================================================ +// Let users send badges to each other within your app +// +// How it works: +// 1. Your app calls initiateTemplateIssuance with a template URI +// 2. User selects a recipient from their contacts +// 3. Badge is sent from your app on behalf of the user + +async function sendPeerBadge() { + try { + await learnCard.initiateTemplateIssuance({ + // TODO: Replace with your peer badge template URI + // Create templates at: /admin-tools/view-managed-boosts + boostUri: 'urn:lc:boost:YOUR_PEER_BADGE_TEMPLATE', + }); + + console.log('Peer badge flow initiated'); + } catch (error) { + console.error('Failed to initiate peer badge:', error); + throw error; + } +}`); + } + + // =================== + // REQUEST CREDENTIALS + // =================== + if (selectedFeatures.includes('request-credentials')) { + const mode = requestCredentialsState.mode as string || 'query'; + const queryTitle = requestCredentialsState.queryTitle as string || 'Certificate'; + const queryReason = requestCredentialsState.queryReason as string || 'To verify your qualifications'; + + if (mode === 'query') { + sections.push(` +// ============================================================ +// REQUEST CREDENTIALS - Search by Title +// ============================================================ +// Search user's wallet for credentials matching a title +// User selects which credential(s) to share +// +// Configure your search: +// - Title: "${queryTitle}" +// - Reason: "${queryReason}" + +async function requestCredentialsBySearch() { + try { + const response = await learnCard.askCredentialSearch({ + query: [ + { + type: 'QueryByTitle', + credentialQuery: { + reason: "${queryReason}", + title: "${queryTitle}" + } + } + ], + challenge: crypto.randomUUID(), // Unique challenge for verification + }); + + if (response.presentation) { + // User shared credentials - verify the presentation + console.log('Received presentation:', response.presentation); + + // Extract credentials from the presentation + const credentials = response.presentation.verifiableCredential || []; + console.log('Shared credentials:', credentials.length); + + // TODO: Send to your server for verification + // await verifyPresentation(response.presentation); + + return credentials; + } else { + console.log('User did not share any credentials'); + return []; + } + } catch (error) { + if (error.code === 'USER_REJECTED') { + console.log('User declined to share credentials'); + } else { + console.error('Error requesting credentials:', error); + } + throw error; + } +}`); + } else { + sections.push(` +// ============================================================ +// REQUEST CREDENTIALS - Request by ID +// ============================================================ +// Request a specific credential by its ID +// User accepts or declines sharing that exact credential + +async function requestCredentialById(credentialId: string) { + try { + const response = await learnCard.askCredentialById({ + id: credentialId, + reason: "${queryReason || 'Please share this credential for verification'}", + challenge: crypto.randomUUID(), + }); + + if (response.credential) { + console.log('Received credential:', response.credential); + + // TODO: Send to your server for verification + // await verifyCredential(response.credential); + + return response.credential; + } else { + console.log('Credential not returned'); + return null; + } + } catch (error) { + if (error.code === 'CREDENTIAL_NOT_FOUND') { + console.log('Credential not found in user wallet'); + } else if (error.code === 'USER_REJECTED') { + console.log('User declined to share'); + } else { + console.error('Error:', error); + } + throw error; + } +}`); + } + } + + // =================== + // REQUEST DATA CONSENT + // =================== + if (selectedFeatures.includes('request-data-consent')) { + const contractUri = requestDataConsentState.contractUri as string || ''; + + sections.push(` +// ============================================================ +// REQUEST DATA CONSENT +// ============================================================ +// Ask users for permission to access specific data or write back +// Uses ConsentFlow contracts for OAuth-style consent +// +// Contract URI: ${contractUri || 'NOT_CONFIGURED - Create one in Admin Tools'} +// +// How it works: +// 1. Client requests consent from user +// 2. User reviews and grants/denies permissions +// 3. Your server can then read/write data per contract terms + +// --- CLIENT-SIDE: Request consent --- +async function requestDataConsent() { + try { + const result = await learnCard.requestConsent({ + contractUri: '${contractUri || 'urn:lc:contract:YOUR_CONTRACT_URI'}', + }); + + if (result.granted) { + console.log('User granted consent!'); + console.log('User ID:', result.userId); + + // Notify your server that consent was granted + await fetch('/api/consent-granted', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: result.userId, + contractUri: '${contractUri || 'urn:lc:contract:YOUR_CONTRACT_URI'}' + }) + }); + + return true; + } else { + console.log('User declined consent'); + return false; + } + } catch (error) { + console.error('Failed to request consent:', error); + throw error; + } +} + +// --- SERVER-SIDE CODE (Node.js) --- +// Use these on YOUR server to read/write consented data +/* +import { initLearnCard } from '@learncard/init'; + +const learnCard = await initLearnCard({ + network: true, + apiKey: process.env.LEARNCARD_API_KEY, // Or use seed +}); + +const CONTRACT_URI = '${contractUri || 'urn:lc:contract:YOUR_CONTRACT_URI'}'; + +// Read credentials user shared via consent +async function readConsentedCredentials() { + const credentials = await learnCard.invoke.getCredentialsForContract( + CONTRACT_URI, + { limit: 50 } + ); + return credentials.records; +} + +// Write credential to user via consent +async function writeCredentialViaConsent(recipientProfileId: string) { + return await learnCard.invoke.send({ + type: 'boost', + recipient: recipientProfileId, + templateUri: 'urn:lc:boost:YOUR_TEMPLATE_URI', + contractUri: CONTRACT_URI, + }); +} +*/`); + } + + // =================== + // LAUNCH FEATURE + // =================== + if (selectedFeatures.includes('launch-feature')) { + const selectedFeatureIds = launchFeatureState.selectedFeatureIds as string[] || []; + const paramValues = launchFeatureState.paramValues as Record> || {}; + + // Get all selected launchable features + const selectedLaunchFeatures = LAUNCHABLE_FEATURES + .flatMap(cat => cat.features) + .filter(f => selectedFeatureIds.includes(f.id)); + + // Generate code for each selected feature + const featureFunctions = selectedLaunchFeatures.map(feature => { + let featurePath = feature.path; + const featureParams = paramValues[feature.id] || {}; + + // Replace path params with configured values + if (feature.params) { + feature.params.forEach(param => { + const value = featureParams[param.name] || param.placeholder; + featurePath = featurePath.replace(`:${param.name}`, value); + }); + } + + const funcName = `launch${feature.title.replace(/[^a-zA-Z0-9]/g, '')}`; + + return `// Launch ${feature.title} +async function ${funcName}() { + await learnCard.launchFeature('${featurePath}'); +}`; + }); + + const featureList = selectedLaunchFeatures.length > 0 + ? selectedLaunchFeatures.map(f => `// - ${f.title} (${f.path})`).join('\n') + : '// No features selected'; + + sections.push(` +// ============================================================ +// LAUNCH FEATURE +// ============================================================ +// Trigger native LearnCard features from your app +// +// Selected features: +${featureList} + +${featureFunctions.join('\n\n')} + +// Additional launch examples: +// await learnCard.launchFeature('/passport'); // Open wallet +// await learnCard.launchFeature('/chats'); // Open AI chat +// await learnCard.launchFeature('/notifications'); // Open notifications +// await learnCard.launchFeature('/connect'); // Open connections`); + } + + // =================== + // EXAMPLE USAGE + // =================== + sections.push(` +// ============================================================ +// EXAMPLE: Main App Initialization +// ============================================================ +async function initializeApp() { + try { + // 1. Get user identity on app load + const identity = await getUserIdentity(); + console.log('App initialized for user:', identity.profile.displayName); + + // 2. Your app logic here... + // Call the feature functions above based on user actions + + } catch (error) { + console.error('App initialization failed:', error); + } +} + +// Initialize when the app loads +initializeApp();`); + + return sections.join('\n'); + }; + + const code = generateCode(); + + const handleCopy = async () => { + await navigator.clipboard.writeText(code); + setCopiedCode(true); + setTimeout(() => setCopiedCode(false), 2000); + }; + + // Compute config differences for display + const configDifferences = (() => { + const newConfig = computeNewConfig(); + const existingPermissions = existingConfig.permissions || []; + const differences: { type: string; label: string; from: string; to: string }[] = []; + + // Find new permissions + const newPerms = newConfig.permissions?.filter(p => !existingPermissions.includes(p)) || []; + if (newPerms.length > 0) { + differences.push({ + type: 'permissions_added', + label: 'New permissions required', + from: existingPermissions.length > 0 ? existingPermissions.map(p => PERMISSION_OPTIONS.find(o => o.value === p)?.label || p).join(', ') : 'None', + to: newPerms.map(p => PERMISSION_OPTIONS.find(o => o.value === p)?.label || p).join(', '), + }); + } + + // Check consent contract + const newContractUri = newConfig.contractUri || ''; + const existingContractUri = existingConfig.contractUri || ''; + if (newContractUri !== existingContractUri && newContractUri) { + differences.push({ + type: 'contract', + label: 'Consent contract', + from: existingContractUri || 'Not set', + to: newContractUri, + }); + } + + return differences; + })(); + + return ( +
+ {/* Config Mismatch Modal */} + {showConfigMismatchPrompt && configDifferences.length > 0 && ( +
+
+
+
+ +
+ +
+

Update App Configuration?

+ +

+ Your selected features require different permissions than your app currently has configured. +

+
+
+ +
+

Changes needed:

+ + {configDifferences.map((diff, idx) => ( +
+

{diff.label}

+ +
+ {diff.from} + + {diff.to} +
+
+ ))} +
+ +
+ + + +
+
+
+ )} + +
+

Your Integration Code

+ +

+ Here's your complete integration code with {selectedFeatures.length} feature{selectedFeatures.length !== 1 ? 's' : ''} configured. + Copy this code to get started or share with your development team. +

+
+ + {/* App Summary */} + {selectedListing && ( +
+
+
+ {selectedListing.icon_url ? ( + {selectedListing.display_name} + ) : ( + + )} +
+ +
+

{selectedListing.display_name}

+

App ID: {selectedListing.listing_id}

+
+
+
+ )} + + {/* Selected features summary */} +
+

Configured Features

+ +
+ {selectedFeatures.map(id => { + const feature = FEATURES.find(f => f.id === id); + + return feature ? ( + + + {feature.title} + + ) : null; + })} +
+
+ + {/* App Configuration Section */} +
+ + + {showConfigEditor && ( +
+ {/* Embed URL */} +
+ + + setEmbedUrl(e.target.value)} + placeholder="https://yourapp.com/embed" + className="w-full px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500" + /> + +

+ The URL that will be loaded in the iframe when users open your app +

+
+ + {/* Permissions */} +
+ + +

+ Based on your selected features, these permissions are required. You can add more if needed. +

+ +
+ {PERMISSION_OPTIONS.map(permission => { + const isRequired = computeRequiredPermissions().includes(permission.value); + const isSelected = selectedPermissions.includes(permission.value); + + return ( + + ); + })} +
+
+ + {/* Consent Flow Contract */} +
+ + +

+ Request data sharing permissions when users install your app. + {selectedFeatures.includes('request-data-consent') && (requestDataConsentState.contractUri as string) && ( + Auto-filled from your Request Data Consent setup. + )} +

+ + +
+ + {/* Save Button */} +
+

+ Changes will be saved to your app listing +

+ + +
+
+ )} +
+ + {/* Preview App Button */} + {selectedListing && embedUrl && ( +
+
+
+

Test Your Integration

+ +

+ Preview your app and validate partner-connect API calls +

+
+ + +
+
+ )} + + {/* Code output */} +
+
+ Complete Integration Code + + +
+ + +
+ + {/* Tips */} +
+

+ + Integration Tips +

+ +
    +
  • • Replace all TODO placeholders with your actual values
  • +
  • • Server-side code (in comments) should run on YOUR server, not in the embedded app
  • +
  • • Store sensitive values (seeds, API keys) in environment variables
  • +
  • • Test in the LearnCard sandbox before going live
  • +
+
+ + {/* Resources */} + + + {/* Navigation */} +
+ +
+
+ ); +}; + +// Main component +const EmbedAppGuide: React.FC = ({ selectedIntegration, setSelectedIntegration }) => { + const guideState = useGuideState('embed-app', STEPS.length); + + // Guide-wide state (persists across all steps) + const [selectedListing, setSelectedListing] = useState(null); + const [selectedFeatures, setSelectedFeatures] = useState([]); + const [currentFeatureIndex, setCurrentFeatureIndex] = useState(0); + const [featureSetupState, setFeatureSetupState] = useState>>({}); + + // Reset guide if step is out of bounds (e.g., after changing step count) + useEffect(() => { + if (guideState.currentStep >= STEPS.length) { + guideState.goToStep(0); + } + }, [guideState.currentStep]); + + // Check if we should skip feature setup step + const featuresNeedingSetup = selectedFeatures.filter(id => + FEATURES.find(f => f.id === id)?.requiresSetup + ); + + // Reset feature index when features change to prevent out-of-bounds issues + useEffect(() => { + // If current index is out of bounds, reset to 0 + if (currentFeatureIndex >= featuresNeedingSetup.length) { + setCurrentFeatureIndex(0); + } + }, [featuresNeedingSetup.length, currentFeatureIndex]); + + // Clean up feature setup state when features are deselected + useEffect(() => { + setFeatureSetupState(prev => { + const newState = { ...prev }; + // Remove state for features that are no longer selected + Object.keys(newState).forEach(featureId => { + if (!selectedFeatures.includes(featureId)) { + delete newState[featureId]; + } + }); + return newState; + }); + }, [selectedFeatures]); + + const handleStepComplete = (stepId: string) => { + guideState.markStepComplete(stepId); + guideState.nextStep(); + }; + + const handleChooseFeaturesComplete = () => { + if (featuresNeedingSetup.length === 0) { + // Skip feature setup, go directly to your app + guideState.markStepComplete('choose-features'); + guideState.markStepComplete('feature-setup'); + guideState.goToStep(3); + } else { + handleStepComplete('choose-features'); + } + }; + + const renderStep = () => { + switch (guideState.currentStep) { + case 0: + return ( + handleStepComplete('getting-started')} + selectedIntegration={selectedIntegration} + selectedListing={selectedListing} + setSelectedListing={setSelectedListing} + /> + ); + + case 1: + return ( + + ); + + case 2: + // If no features need setup, skip to step 3 + if (featuresNeedingSetup.length === 0) { + // Auto-advance to next step + setTimeout(() => { + guideState.markStepComplete('feature-setup'); + guideState.goToStep(3); + }, 0); + return null; + } + + return ( + handleStepComplete('feature-setup')} + onBack={guideState.prevStep} + selectedFeatures={selectedFeatures} + currentFeatureIndex={currentFeatureIndex} + setCurrentFeatureIndex={setCurrentFeatureIndex} + featureSetupState={featureSetupState} + setFeatureSetupState={setFeatureSetupState} + selectedListing={selectedListing} + /> + ); + + case 3: + return ( + + ); + + default: + return null; + } + }; + + return ( +
+
+ +
+ + {renderStep()} +
+ ); +}; + +export default EmbedAppGuide; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/EmbedClaimGuide.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/EmbedClaimGuide.tsx new file mode 100644 index 0000000000..733fe2d9f2 --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/EmbedClaimGuide.tsx @@ -0,0 +1,1013 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Key, + Code, + Package, + Settings, + Play, + ArrowRight, + ArrowLeft, + ExternalLink, + CheckCircle2, + Copy, + Check, + Loader2, + Building2, + Sparkles, + Award, + ChevronDown, + ChevronUp, + Upload, + Palette, +} from 'lucide-react'; +import type { LCNIntegration } from '@learncard/types'; + +import { useToast, ToastTypeEnum, useConfirmation, useFilestack, useWallet } from 'learn-card-base'; +import { Clipboard } from '@capacitor/clipboard'; + +import { StepProgress, CodeOutputPanel, StatusIndicator } from '../shared'; +import { useGuideState } from '../shared/useGuideState'; +import { OBv3CredentialBuilder } from '../../../../components/credentials/OBv3CredentialBuilder'; +import type { GuideProps } from '../GuidePage'; + +const STEPS = [ + { id: 'publishable-key', title: 'Get Publishable Key' }, + { id: 'add-target', title: 'Add HTML Target' }, + { id: 'load-sdk', title: 'Load SDK' }, + { id: 'configure', title: 'Configure' }, + { id: 'test', title: 'Test It' }, +]; + +// Step 1: Publishable Key (shows key from selected integration) +const PublishableKeyStep: React.FC<{ + onComplete: () => void; + selectedIntegration: LCNIntegration | null; +}> = ({ onComplete, selectedIntegration }) => { + const { presentToast } = useToast(); + const [copied, setCopied] = useState(false); + + const publishableKey = selectedIntegration?.publishableKey || ''; + + const copyKey = async () => { + if (!publishableKey) return; + + await Clipboard.write({ string: publishableKey }); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + presentToast('Key copied!', { hasDismissButton: true }); + }; + + return ( +
+
+

Your Publishable Key

+ +

+ Use this publishable key to authenticate credential claims from your website. + It's safe to expose in client-side code. +

+
+ + {/* Status */} + + + {/* Selected Key Display */} + {selectedIntegration ? ( +
+
+ + + +
+ +
+ {publishableKey} +
+ +

+ This key can only be used to claim credentials. Keep your secret key secure on your server. +

+
+ ) : ( +
+ + +

No Project Selected

+ +

+ Select or create a project using the dropdown in the header to continue. +

+
+ )} + + +
+ ); +}; + +// Step 2: Add Target +const AddTargetStep: React.FC<{ + onComplete: () => void; + onBack: () => void; +}> = ({ onComplete, onBack }) => { + return ( +
+
+

Add HTML Target Element

+ +

+ Add a container element to your page where the "Claim Credential" button will appear. +

+
+ + +
+ + +
+

Congratulations!

+

You completed the course. Claim your credential below.

+ + +
+
`, + }} + /> + +
+

What gets rendered

+ +

+ The SDK replaces the target element with a styled button. When clicked, it opens a modal + for the user to verify their email and claim the credential. +

+ +
+
+ Claim "Course Completion" +
+ + ← Example button +
+
+ +
+ + + +
+
+ ); +}; + +// Step 3: Load SDK +const LoadSdkStep: React.FC<{ + onComplete: () => void; + onBack: () => void; +}> = ({ onComplete, onBack }) => { + const [method, setMethod] = useState<'cdn' | 'npm'>('cdn'); + + return ( +
+
+

Load the Embed SDK

+ +

+ Choose how to load the SDK in your project. +

+
+ +
+ + + +
+ + {method === 'cdn' ? ( + tag --> + + + +`, + }} + /> + ) : ( + + )} + +
+

Zero dependencies

+ +

+ The SDK is a single optimized file (~15KB gzipped) with no external dependencies. + It works on any website — no React, Vue, or framework required. +

+
+ +
+ + + +
+
+ ); +}; + +// Step 4: Configure +const ConfigureStep: React.FC<{ + onComplete: () => void; + onBack: () => void; + publishableKey: string; + selectedIntegration: LCNIntegration | null; + credential: Record; + setCredential: (cred: Record) => void; + partnerName: string; + setPartnerName: (name: string) => void; + branding: { + primaryColor: string; + accentColor: string; + partnerLogoUrl: string; + }; + setBranding: (branding: { primaryColor: string; accentColor: string; partnerLogoUrl: string }) => void; + requestBackgroundIssuance: boolean; + setRequestBackgroundIssuance: (value: boolean) => void; +}> = ({ + onComplete, + onBack, + publishableKey, + selectedIntegration, + credential, + setCredential, + partnerName, + setPartnerName, + branding, + setBranding, + requestBackgroundIssuance, + setRequestBackgroundIssuance, +}) => { + const { presentToast } = useToast(); + const [isBuilderOpen, setIsBuilderOpen] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [keyCopied, setKeyCopied] = useState(false); + const { initWallet } = useWallet(); + const [userDid, setUserDid] = useState(''); + + // Fetch user's DID on mount + useEffect(() => { + const fetchDid = async () => { + try { + const wallet = await initWallet(); + const did = wallet.id.did(); + setUserDid(did); + } catch (err) { + console.error('Failed to get DID:', err); + } + }; + fetchDid(); + }, []); + + // Logo upload via Filestack + const { handleFileSelect: handleLogoUpload, isLoading: isUploadingLogo } = useFilestack({ + onUpload: (url: string) => { + setBranding({ ...branding, partnerLogoUrl: url }); + }, + fileType: 'image/*', + }); + + const handleCredentialSave = (newCredential: Record) => { + // Add issuer if we have the DID + if (userDid) { + newCredential.issuer = userDid; + } + setCredential(newCredential); + }; + + // Extract display info from credential + const credentialName = (credential.name as string) || 'Untitled Credential'; + const credentialSubject = credential.credentialSubject as Record | undefined; + const achievement = credentialSubject?.achievement as Record | undefined; + const credentialDescription = (achievement?.description as string) || ''; + const achievementImage = (achievement?.image as { id?: string })?.id || (achievement?.image as string) || ''; + + // Check if branding is set + const hasBranding = branding.primaryColor !== '#1F51FF' || branding.partnerLogoUrl; + + // Format credential JSON for code snippets + const credentialJson = useMemo(() => JSON.stringify(credential, null, 4), [credential]); + const credentialJsonIndented = credentialJson.split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n'); + + // Format branding object for code + const brandingCode = `{ + primaryColor: '${branding.primaryColor}', + accentColor: '${branding.accentColor}',${branding.partnerLogoUrl ? ` + partnerLogoUrl: '${branding.partnerLogoUrl}'` : ''} + }`; + + const getCode = () => { + const partner = partnerName || 'Your Company'; + + return `LearnCard.init({ + // Your publishable key from the Developer Portal + publishableKey: '${publishableKey}', + + // Partner branding + partnerName: '${partner}', + + // Where to render the claim button + target: '#claim-target', + + // The credential to issue (built with Credential Builder) + credential: ${credentialJsonIndented}, + + // Custom branding for the claim modal + branding: ${brandingCode}, + ${requestBackgroundIssuance ? ` + // Request consent for future credential issuance + requestBackgroundIssuance: true, + ` : ''} + // Called when credential is successfully claimed + onSuccess: ({ credentialId, consentGiven }) => { + console.log('Claimed!', credentialId);${requestBackgroundIssuance ? ` + if (consentGiven) { + console.log('User consented to future issuance!'); + }` : ''} + // Show success message, redirect, etc. + } +});`; + }; + + return ( +
+
+

Configure the SDK

+ +

+ Build your credential and customize branding for the claim experience. +

+
+ + {/* Publishable Key Display */} + {publishableKey ? ( +
+
+
+ + +
+ + +
+ +
+ {selectedIntegration && ( + + {selectedIntegration.name} + + )} + + + {publishableKey} + +
+ +

+ Change your project using the dropdown in the header. +

+
+ ) : ( +
+
+
+ +
+ +
+

No Project Selected

+ +

+ Select or create a project using the dropdown in the header to continue. +

+
+
+
+ )} + + {/* Credential preview card */} +
+
+ {/* Credential icon/image */} + {achievementImage ? ( + {credentialName} { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); + }} + /> + ) : null} + +
+ +
+ + {/* Credential info */} +
+

{credentialName}

+ +

+ {credentialDescription || 'No description set'} +

+ +
+ + {(achievement?.achievementType as string) || 'Achievement'} + +
+
+ + {/* Edit button */} + +
+ + {/* Open builder button if using default */} + {credentialName === 'Achievement Badge' && ( + + )} +
+ + {/* Credential Builder Modal */} + setIsBuilderOpen(false)} + onSave={handleCredentialSave} + /> + + {/* Partner Name */} +
+ + + setPartnerName(e.target.value)} + placeholder="e.g., Your Company" + className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-500" + style={{ colorScheme: 'light' }} + /> + +

+ Shown in the claim modal as the issuing organization +

+
+ + {/* Advanced Options Toggle */} + + + {/* Advanced Options Panel */} + {showAdvanced && ( +
+ {/* Branding Section */} +
+
+ + Modal Branding +
+ +
+
+ + +
+ setBranding({ ...branding, primaryColor: e.target.value })} + className="w-10 h-10 rounded-lg border border-gray-300 cursor-pointer" + /> + + setBranding({ ...branding, primaryColor: e.target.value })} + placeholder="#1F51FF" + className="flex-1 px-3 py-2 text-sm font-mono bg-white border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500" + /> +
+
+ +
+ + +
+ setBranding({ ...branding, accentColor: e.target.value })} + className="w-10 h-10 rounded-lg border border-gray-300 cursor-pointer" + /> + + setBranding({ ...branding, accentColor: e.target.value })} + placeholder="#0F3BD9" + className="flex-1 px-3 py-2 text-sm font-mono bg-white border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500" + /> +
+
+ +
+ + +
+ setBranding({ ...branding, partnerLogoUrl: e.target.value })} + placeholder="https://example.com/logo.png" + className="flex-1 px-3 py-2 text-sm bg-white border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500" + disabled={isUploadingLogo} + style={{ colorScheme: 'light' }} + /> + + +
+ + {branding.partnerLogoUrl && ( + Logo preview { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + )} +
+
+
+ + {/* Background Issuance Section */} +
+
+ + Advanced Settings +
+ + +
+ + {/* Color Preview */} +
+

Button Preview

+ +
+ Claim "{credentialName}" +
+
+
+ )} + + + +
+

Important Options

+ +
    +
  • publishableKey — Required for real claims
  • +
  • credential — Built using the Credential Builder above
  • +
  • branding — Customize the claim modal appearance
  • +
  • requestBackgroundIssuance — Ask consent for future issuance
  • +
  • onSuccess — Handle post-claim actions
  • +
+
+ +
+ + + +
+
+ ); +}; + +// Step 5: Test +const TestStep: React.FC<{ + onBack: () => void; + publishableKey: string; + credential: Record; + partnerName: string; +}> = ({ onBack, publishableKey, credential, partnerName }) => { + const credentialName = (credential.name as string) || 'Untitled Credential'; + return ( +
+
+

Test Your Integration

+ +

+ Here's a checklist and the user flow to verify everything works. +

+
+ + {/* Checklist */} +
+

Pre-flight checklist

+ +
+
+ + Publishable key configured +
+ +
+ + Credential name set +
+ +
+ + SDK loaded on page +
+ +
+ + Target element exists +
+
+
+ + {/* User flow */} +
+

User Experience Flow

+ +
+
+
1
+ +
+

User clicks the claim button

+

Opens a branded modal

+
+
+ +
+
2
+ +
+

User enters their email

+

A 6-digit code is sent to verify

+
+
+ +
+
3
+ +
+

User enters the OTP code

+

Credential is issued to their wallet

+
+
+ +
+
+ +
+

Success!

+

LearnCard wallet opens, onSuccess is called

+
+
+
+
+ + {/* Returning users */} +
+

Returning Users

+ +

+ The SDK remembers logged-in users via localStorage. On their next visit, they'll see an + "Accept Credential" button instead of entering email/OTP again. +

+
+ + {/* Success */} +
+
+ +
+ +

Ready to go live!

+ +

+ Users can now claim credentials directly from your website. +

+ + + View full example on GitHub + + +
+ +
+ + + + Full Documentation + + +
+
+ ); +}; + +// Main component +const EmbedClaimGuide: React.FC = ({ selectedIntegration, setSelectedIntegration }) => { + const guideState = useGuideState('embed-claim', STEPS.length); + + // Derive publishable key from selected integration + const publishableKey = selectedIntegration?.publishableKey || ''; + + const [credential, setCredential] = useState>({ + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json', + ], + type: ['VerifiableCredential', 'OpenBadgeCredential'], + name: 'Achievement Badge', + credentialSubject: { + type: ['AchievementSubject'], + achievement: { + type: ['Achievement'], + name: 'Achievement Badge', + description: 'Awarded for completing the course', + achievementType: 'Achievement', + }, + }, + }); + const [partnerName, setPartnerName] = useState(''); + const [branding, setBranding] = useState({ + primaryColor: '#1F51FF', + accentColor: '#0F3BD9', + partnerLogoUrl: '', + }); + const [requestBackgroundIssuance, setRequestBackgroundIssuance] = useState(false); + + const handleStepComplete = (stepId: string) => { + guideState.markStepComplete(stepId); + guideState.nextStep(); + }; + + const renderStep = () => { + switch (guideState.currentStep) { + case 0: + return ( + handleStepComplete('publishable-key')} + selectedIntegration={selectedIntegration} + /> + ); + + case 1: + return ( + handleStepComplete('add-target')} + onBack={guideState.prevStep} + /> + ); + + case 2: + return ( + handleStepComplete('load-sdk')} + onBack={guideState.prevStep} + /> + ); + + case 3: + return ( + handleStepComplete('configure')} + onBack={guideState.prevStep} + publishableKey={publishableKey} + selectedIntegration={selectedIntegration} + credential={credential} + setCredential={setCredential} + partnerName={partnerName} + setPartnerName={setPartnerName} + branding={branding} + setBranding={setBranding} + requestBackgroundIssuance={requestBackgroundIssuance} + setRequestBackgroundIssuance={setRequestBackgroundIssuance} + /> + ); + + case 4: + return ( + + ); + + default: + return null; + } + }; + + return ( +
+
+ +
+ + {renderStep()} +
+ ); +}; + +export default EmbedClaimGuide; diff --git a/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/IssueCredentialsGuide.tsx b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/IssueCredentialsGuide.tsx new file mode 100644 index 0000000000..21c5ac39e0 --- /dev/null +++ b/apps/learn-card-app/src/pages/appStoreDeveloper/guides/useCases/IssueCredentialsGuide.tsx @@ -0,0 +1,1468 @@ +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import { + Key, + Shield, + FileCode, + Rocket, + ArrowRight, + ArrowLeft, + Check, + Loader2, + Plus, + Copy, + Trash2, + CheckCircle2, + AlertCircle, + Award, + Sparkles, + Eye, + ChevronDown, + ChevronUp, + Building2, + Link as LinkIcon, + BellOff, + Webhook, + RefreshCw, + Send, + Clock, + Upload, +} from 'lucide-react'; + +import { useWallet, useToast, ToastTypeEnum, useConfirmation, useFilestack } from 'learn-card-base'; +import { networkStore } from 'learn-card-base/stores/NetworkStore'; +import { LEARNCARD_NETWORK_API_URL } from 'learn-card-base/constants/Networks'; +import { Clipboard } from '@capacitor/clipboard'; + +import { StepProgress, CodeOutputPanel, StatusIndicator } from '../shared'; +import { useGuideState } from '../shared/useGuideState'; +import { OBv3CredentialBuilder } from '../../../../components/credentials/OBv3CredentialBuilder'; + +type AuthGrant = { + id: string; + name: string; + challenge: string; + createdAt: string; + status: 'revoked' | 'active'; + scope: string; + description?: string; +}; + +const STEPS = [ + { id: 'api-token', title: 'Create API Token' }, + { id: 'signing-authority', title: 'Set Up Signing' }, + { id: 'build-credential', title: 'Build Credential' }, + { id: 'issue', title: 'Issue & Verify' }, +]; + +const SCOPE_OPTIONS = [ + { label: 'Full Access', value: '*:*', description: 'Complete access to all resources' }, + { label: 'Credentials Only', value: 'credential:* presentation:*', description: 'Issue and manage credentials' }, +]; + +// Step 1: API Token +const ApiTokenStep: React.FC<{ + onComplete: () => void; + onTokenCreated: (token: string) => void; +}> = ({ onComplete, onTokenCreated }) => { + const { initWallet } = useWallet(); + const { presentToast } = useToast(); + const confirm = useConfirmation(); + + const [authGrants, setAuthGrants] = useState[]>([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [newTokenName, setNewTokenName] = useState(''); + const [selectedScope, setSelectedScope] = useState('*:*'); + const [showCreateForm, setShowCreateForm] = useState(false); + const [copiedId, setCopiedId] = useState(null); + + const fetchAuthGrants = useCallback(async () => { + try { + const wallet = await initWallet(); + setLoading(true); + const grants = await wallet.invoke.getAuthGrants(); + setAuthGrants(grants || []); + } catch (err) { + console.error('Failed to fetch auth grants:', err); + } finally { + setLoading(false); + } + }, [initWallet]); + + useEffect(() => { + fetchAuthGrants(); + }, []); + + const createToken = async () => { + if (!newTokenName.trim()) return; + + try { + setCreating(true); + const wallet = await initWallet(); + + await wallet.invoke.addAuthGrant({ + name: newTokenName.trim(), + description: 'Created from Integration Guide', + scope: selectedScope, + }); + + presentToast('API Token created!', { hasDismissButton: true }); + setNewTokenName(''); + setShowCreateForm(false); + fetchAuthGrants(); + } catch (err) { + console.error('Failed to create token:', err); + presentToast('Failed to create token', { type: ToastTypeEnum.Error, hasDismissButton: true }); + } finally { + setCreating(false); + } + }; + + const copyToken = async (id: string) => { + try { + const wallet = await initWallet(); + const token = await wallet.invoke.getAPITokenForAuthGrant(id); + + await Clipboard.write({ string: token }); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + onTokenCreated(token); + presentToast('Token copied!', { hasDismissButton: true }); + } catch (err) { + console.error('Failed to copy token:', err); + presentToast('Failed to copy token', { type: ToastTypeEnum.Error, hasDismissButton: true }); + } + }; + + const revokeToken = async (grant: Partial) => { + const confirmed = await confirm({ + text: `Delete "${grant.name}"?`, + onConfirm: async () => {}, + cancelButtonClassName: 'cancel-btn text-grayscale-900 bg-grayscale-200 py-2 rounded-[40px] font-bold px-2 w-[100px]', + confirmButtonClassName: 'confirm-btn bg-grayscale-900 text-white py-2 rounded-[40px] font-bold px-2 w-[100px]', + }); + + if (!confirmed) return; + + try { + const wallet = await initWallet(); + + if (grant.status === 'active') { + await wallet.invoke.revokeAuthGrant(grant.id!); + } else { + await wallet.invoke.deleteAuthGrant(grant.id!); + } + + presentToast('Token removed', { hasDismissButton: true }); + fetchAuthGrants(); + } catch (err) { + console.error('Failed to remove token:', err); + } + }; + + const activeGrants = authGrants.filter(g => g.status === 'active'); + const hasActiveToken = activeGrants.length > 0; + + return ( +
+
+

Create an API Token

+ +

+ Your server needs an API token to authenticate with LearnCard. This token should be kept secret + and never exposed in client-side code. +

+
+ + {/* Status */} + 1 ? 's' : ''} ready` : 'No API tokens found'} + description={hasActiveToken ? 'Copy a token to use in your code' : 'Create one to continue'} + /> + + {/* Create form */} + {showCreateForm && ( +
+
+ + + setNewTokenName(e.target.value)} + placeholder="e.g., Production Server" + className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ + + + +

+ {SCOPE_OPTIONS.find(o => o.value === selectedScope)?.description} +

+
+ +
+ + + +
+
+ )} + + {/* Token list */} + {!loading && activeGrants.length > 0 && ( +
+ {activeGrants.map((grant) => ( +
+
+

{grant.name}

+ +

+ Created {new Date(grant.createdAt!).toLocaleDateString()} +

+
+ +
+ + + +
+
+ ))} +
+ )} + + {/* Create button */} + {!showCreateForm && ( + + )} + + {/* Security warning */} +
+

+ Security: Never expose your API token in client-side code or commit it to version control. +

+
+ + {/* Continue button */} + +
+ ); +}; + +// Step 2: Signing Authority +const SigningAuthorityStep: React.FC<{ + onComplete: () => void; + onBack: () => void; +}> = ({ onComplete, onBack }) => { + const { initWallet } = useWallet(); + const { presentToast } = useToast(); + + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [primarySA, setPrimarySA] = useState<{ name: string; endpoint: string } | null>(null); + + const fetchSigningAuthority = useCallback(async () => { + try { + setLoading(true); + const wallet = await initWallet(); + const primary = await wallet.invoke.getPrimaryRegisteredSigningAuthority(); + + if (primary?.relationship) { + setPrimarySA({ + name: primary.relationship.name, + endpoint: primary.signingAuthority?.endpoint ?? '', + }); + } else { + setPrimarySA(null); + } + } catch (err) { + console.error('Failed to fetch signing authority:', err); + setPrimarySA(null); + } finally { + setLoading(false); + } + }, [initWallet]); + + useEffect(() => { + fetchSigningAuthority(); + }, []); + + const createSigningAuthority = async () => { + try { + setCreating(true); + const wallet = await initWallet(); + + const authority = await wallet.invoke.createSigningAuthority('default-sa'); + + if (!authority) { + throw new Error('Failed to create signing authority'); + } + + await wallet.invoke.registerSigningAuthority( + authority.endpoint!, + authority.name, + authority.did! + ); + + await wallet.invoke.setPrimaryRegisteredSigningAuthority( + authority.endpoint!, + authority.name + ); + + presentToast('Signing authority created!', { hasDismissButton: true }); + fetchSigningAuthority(); + } catch (err) { + console.error('Failed to create signing authority:', err); + presentToast('Failed to create signing authority', { type: ToastTypeEnum.Error, hasDismissButton: true }); + } finally { + setCreating(false); + } + }; + + const hasSigningAuthority = primarySA !== null; + + return ( +
+
+

Set Up Signing Authority

+ +

+ A signing authority cryptographically signs your credentials, making them verifiable. + This proves the credentials actually came from you. +

+
+ + {/* Status */} + + + {/* Create button if needed */} + {!loading && !hasSigningAuthority && ( + + )} + + {/* Info about what it does */} +
+

What does this do?

+ +
    +
  • • Creates a cryptographic key pair for signing
  • +
  • • Registers the key with LearnCard's verification network
  • +
  • • Allows anyone to verify credentials you issue
  • +
+
+ + {/* Navigation */} +
+ + + +
+
+ ); +}; + +// Advanced options type +interface AdvancedOptions { + issuerName: string; + issuerLogoUrl: string; + recipientName: string; + suppressDelivery: boolean; + webhookUrl: string; +} + +// Step 3: Build Credential +const BuildCredentialStep: React.FC<{ + onComplete: () => void; + onBack: () => void; + apiToken: string; + onTokenChange: (token: string) => void; +}> = ({ onComplete, onBack, apiToken, onTokenChange }) => { + const [isBuilderOpen, setIsBuilderOpen] = useState(false); + const [recipientEmail, setRecipientEmail] = useState(''); + const [userDid, setUserDid] = useState(''); + + // API Token selector state + const [authGrants, setAuthGrants] = useState[]>([]); + const [loadingGrants, setLoadingGrants] = useState(false); + const [selectedGrantId, setSelectedGrantId] = useState(null); + const [showTokenSelector, setShowTokenSelector] = useState(false); + + const { initWallet } = useWallet(); + + // Fetch auth grants and DID on mount + useEffect(() => { + const fetchData = async () => { + setLoadingGrants(true); + try { + const wallet = await initWallet(); + + // Fetch grants + const grants = await wallet.invoke.getAuthGrants() || []; + const activeGrants = grants.filter((g: Partial) => g.status === 'active'); + setAuthGrants(activeGrants); + + // Fetch DID + const did = wallet.id.did(); + setUserDid(did); + } catch (err) { + console.error('Failed to fetch data:', err); + } finally { + setLoadingGrants(false); + } + }; + fetchData(); + }, []); + + // Select a token + const selectToken = async (grantId: string) => { + try { + const wallet = await initWallet(); + const token = await wallet.invoke.getAPITokenForAuthGrant(grantId); + onTokenChange(token); + setSelectedGrantId(grantId); + setShowTokenSelector(false); + } catch (err) { + console.error('Failed to get token:', err); + } + }; + + const [credential, setCredential] = useState>({ + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json', + ], + type: ['VerifiableCredential', 'OpenBadgeCredential'], + issuer: '', // Will be set from user's DID + issuanceDate: new Date().toISOString(), + name: 'Achievement Badge', + credentialSubject: { + type: ['AchievementSubject'], + achievement: { + id: 'urn:uuid:' + crypto.randomUUID(), + type: ['Achievement'], + name: 'Achievement Badge', + description: 'Awarded for completing the course', + achievementType: 'Achievement', + criteria: { + narrative: 'Completed the required coursework and assessments.', + }, + }, + }, + }); + + // Update credential issuer when DID is fetched + useEffect(() => { + if (userDid && credential.issuer !== userDid) { + setCredential(prev => ({ ...prev, issuer: userDid })); + } + }, [userDid, credential.issuer]); + + // Advanced options state + const [showAdvanced, setShowAdvanced] = useState(false); + const [advancedOptions, setAdvancedOptions] = useState({ + issuerName: '', + issuerLogoUrl: '', + recipientName: '', + suppressDelivery: false, + webhookUrl: '', + }); + + // Logo upload via Filestack + const { handleFileSelect: handleLogoUpload, isLoading: isUploadingLogo } = useFilestack({ + onUpload: (url: string) => { + setAdvancedOptions(prev => ({ ...prev, issuerLogoUrl: url })); + }, + fileType: 'image/*', + }); + + // Verification polling state + const [isPolling, setIsPolling] = useState(false); + const [pollResult, setPollResult] = useState<{ + success: boolean; + message: string; + count?: number; + latestCredential?: string; + } | null>(null); + const [initialCount, setInitialCount] = useState(null); + const pollIntervalRef = useRef(null); + + // Get the current network URL (check LCN_API_URL env var first, then store, then default) + const networkUrl = LCN_API_URL || networkStore.get.networkUrl() || LEARNCARD_NETWORK_API_URL; + + const handleCredentialSave = (newCredential: Record) => { + setCredential(newCredential); + }; + + // Extract name, description, and image from credential for display + const credentialName = (credential.name as string) || 'Untitled Credential'; + const credentialSubject = credential.credentialSubject as Record | undefined; + const achievement = credentialSubject?.achievement as Record | undefined; + const credentialDescription = (achievement?.description as string) || ''; + const achievementImage = (achievement?.image as { id?: string })?.id || (achievement?.image as string) || ''; + + // Check if any advanced options are set + const hasAdvancedOptions = advancedOptions.issuerName || advancedOptions.issuerLogoUrl || + advancedOptions.recipientName || advancedOptions.suppressDelivery || advancedOptions.webhookUrl; + + // Build configuration object for code snippets + const buildConfigObject = () => { + const config: Record = {}; + + if (advancedOptions.webhookUrl) { + config.webhookUrl = advancedOptions.webhookUrl; + } + + if (advancedOptions.suppressDelivery || advancedOptions.issuerName || advancedOptions.issuerLogoUrl || advancedOptions.recipientName) { + config.delivery = {}; + + if (advancedOptions.suppressDelivery) { + (config.delivery as Record).suppress = true; + } + + if (advancedOptions.issuerName || advancedOptions.issuerLogoUrl || advancedOptions.recipientName) { + (config.delivery as Record).template = { + model: { + ...(advancedOptions.issuerName || advancedOptions.issuerLogoUrl ? { + issuer: { + ...(advancedOptions.issuerName && { name: advancedOptions.issuerName }), + ...(advancedOptions.issuerLogoUrl && { logoUrl: advancedOptions.issuerLogoUrl }), + } + } : {}), + ...(advancedOptions.recipientName ? { + recipient: { name: advancedOptions.recipientName } + } : {}), + } + }; + } + } + + return Object.keys(config).length > 0 ? config : null; + }; + + const configObject = buildConfigObject(); + const configJson = configObject ? JSON.stringify(configObject, null, 4) : null; + const configJsonIndented = configJson ? configJson.split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n') : ''; + + // Format credential JSON for code snippets + const credentialJson = useMemo(() => JSON.stringify(credential, null, 4), [credential]); + const credentialJsonIndented = credentialJson.split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n'); + + // Poll for sent credentials + const checkSentCredentials = useCallback(async () => { + try { + const wallet = await initWallet(); + const result = await wallet.invoke.getMySentInboxCredentials?.({ limit: 10 }); + + if (result?.records) { + const currentCount = result.records.length; + + if (initialCount === null) { + setInitialCount(currentCount); + return null; + } + + if (currentCount > initialCount) { + const latestRecord = result.records[0]; + const latestCred = latestRecord?.credential; + const latestName = typeof latestCred === 'object' && latestCred !== null + ? ((latestCred as Record).name as string) || 'Credential' + : 'Credential'; + return { + success: true, + count: currentCount - initialCount, + latestCredential: latestName, + }; + } + } + + return null; + } catch (err) { + console.error('Failed to check sent credentials:', err); + return null; + } + }, [initWallet, initialCount]); + + const startPolling = useCallback(async () => { + setIsPolling(true); + setPollResult(null); + + // Get initial count + const wallet = await initWallet(); + const result = await wallet.invoke.getMySentInboxCredentials?.({ limit: 10 }); + const count = result?.records?.length ?? 0; + setInitialCount(count); + + // Start polling every 3 seconds + pollIntervalRef.current = setInterval(async () => { + const checkResult = await checkSentCredentials(); + + if (checkResult?.success) { + setPollResult({ + success: true, + message: `New credential sent! "${checkResult.latestCredential}"`, + count: checkResult.count, + }); + stopPolling(); + } + }, 3000); + + // Auto-stop after 2 minutes + setTimeout(() => { + if (pollIntervalRef.current) { + stopPolling(); + if (!pollResult?.success) { + setPollResult({ + success: false, + message: 'Polling timed out. Run your code and try again.', + }); + } + } + }, 120000); + }, [initWallet, checkSentCredentials, pollResult]); + + const stopPolling = useCallback(() => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsPolling(false); + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, []); + + const codeSnippet = `// Install: npm install @learncard/init + +import { initLearnCard } from '@learncard/init'; + +// Your API token from Step 1 +const API_TOKEN = '${apiToken || 'YOUR_API_TOKEN'}'; + +// Initialize LearnCard with your API token +const learnCard = await initLearnCard({ network: true, apiKey: API_TOKEN }); + +// Your credential (built with Credential Builder) +const credential = ${credentialJsonIndented}; +${configObject ? ` +// Configuration for custom branding, webhooks, etc. +const configuration = ${configJsonIndented}; +` : ''} +// Send credential via Universal Inbox (for email recipients)${recipientEmail ? ` +await learnCard.invoke.sendCredentialViaInbox({ + recipient: { + type: 'email', + value: '${recipientEmail}', + }, + credential,${configObject ? ` + configuration,` : ''} +});` : ` +// await learnCard.invoke.sendCredentialViaInbox({ +// recipient: { +// type: 'email', +// value: 'recipient@example.com', +// }, +// credential,${configObject ? ` +// configuration,` : ''} +// });`} +${advancedOptions.suppressDelivery ? ` +// Since delivery is suppressed, get the claim URL from the response: +// const { claimUrl } = await learnCard.invoke.sendCredentialViaInbox({...}); +// Use claimUrl in your own UI or email system.` : ''} +// The recipient will receive an email with a link to claim their credential. +// Once claimed, the credential is signed and added to their wallet. +console.log('Credential sent to inbox!'); + +// Verify your credential was sent: +const sent = await learnCard.invoke.getMySentInboxCredentials(); +console.log('Sent credentials:', sent.records);`; + + const pythonSnippet = `# Install: pip install requests + +import requests +import json + +# Your API token from Step 1 +API_TOKEN = "${apiToken || 'YOUR_API_TOKEN'}" + +# API endpoint +API_URL = "${networkUrl}/inbox/issue" + +# Your credential (built with Credential Builder) +credential = ${credentialJsonIndented.replace(/null/g, 'None').replace(/true/g, 'True').replace(/false/g, 'False')} +${configObject ? ` +# Configuration for custom branding, webhooks, etc. +configuration = ${configJsonIndented.replace(/null/g, 'None').replace(/true/g, 'True').replace(/false/g, 'False')} +` : ''} +# Build the request payload +payload = { + "recipient": { + "type": "email", + "value": "${recipientEmail || 'recipient@example.com'}" + }, + "credential": credential${configObject ? `, + "configuration": configuration` : ''} +} + +# Send credential via Universal Inbox API +response = requests.post( + API_URL, + headers={ + "Authorization": f"Bearer {API_TOKEN}", + "Content-Type": "application/json" + }, + json=payload +) + +if response.ok: + data = response.json() + print("Credential sent to inbox!") + print(f"Claim URL: {data.get('claimUrl', 'N/A')}") +else: + print(f"Error: {response.status_code} - {response.text}")`; + + const curlSnippet = `curl -X POST ${networkUrl}/inbox/issue \\ + -H "Authorization: Bearer ${apiToken || 'YOUR_API_TOKEN'}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "recipient": { + "type": "email", + "value": "${recipientEmail || 'recipient@example.com'}" + }, + "credential": ${JSON.stringify(credential, null, 6).split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n')}${configObject ? `, + "configuration": ${JSON.stringify(configObject, null, 6).split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n')}` : ''} + }'`; + + // Get selected grant name for display + const selectedGrant = authGrants.find(g => g.id === selectedGrantId); + const displayTokenName = selectedGrant?.name || (apiToken ? 'Selected Token' : 'No token selected'); + + return ( +
+
+

Build Your Credential

+ +

+ Design your Open Badges 3.0 credential using our visual builder. The code will update automatically. +

+
+ + {/* API Token Selector */} +
+
+
+
+ +
+ +
+

API Token

+ +

+ {apiToken ? ( + + + {displayTokenName} + + ) : ( + Select a token to use + )} +

+
+
+ + +
+ + {showTokenSelector && ( +
+ {loadingGrants ? ( +
+ + Loading tokens... +
+ ) : authGrants.length === 0 ? ( +

+ No API tokens found. { e.preventDefault(); onBack(); }} className="text-cyan-600 hover:underline">Go back to create one. +

+ ) : ( +
+ {authGrants.map((grant) => ( + + ))} +
+ )} +
+ )} +
+ + {/* Credential preview card */} +
+
+ {/* Credential icon/image */} + {achievementImage ? ( + {credentialName} { + // Fallback to icon if image fails to load + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); + }} + /> + ) : null} + +
+ +
+ + {/* Credential info */} +
+

{credentialName}

+ +

+ {credentialDescription || 'No description set'} +

+ +
+ + {(achievement?.achievementType as string) || 'Achievement'} + +
+
+ + {/* Edit button */} + +
+ + {/* Open builder button if using default */} + {credentialName === 'Achievement Badge' && ( + + )} +
+ + {/* Credential Builder Modal */} + setIsBuilderOpen(false)} + onSave={handleCredentialSave} + /> + + {/* Recipient email */} +
+ + + setRecipientEmail(e.target.value)} + placeholder="recipient@example.com" + className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-500" + style={{ colorScheme: 'light' }} + /> + +

+ Enter an email to see the send code. The recipient will get a claim link. +

+
+ + {/* Advanced Options Toggle */} + + + {/* Advanced Options Panel */} + {showAdvanced && ( +
+

+ Customize branding, webhooks, and delivery options for the Universal Inbox. +

+ + {/* Branding Section */} +
+
+ + Email Branding +
+ +
+
+ + + setAdvancedOptions(prev => ({ ...prev, issuerName: e.target.value }))} + placeholder="Your Organization" + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500" + /> +
+ +
+ + +
+ setAdvancedOptions(prev => ({ ...prev, issuerLogoUrl: e.target.value }))} + placeholder="https://example.com/logo.png" + className="flex-1 px-3 py-2 text-sm bg-white border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500" + disabled={isUploadingLogo} + style={{ colorScheme: 'light' }} + /> + + +
+ + {advancedOptions.issuerLogoUrl && ( + Logo preview { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + )} +
+ +
+ + + setAdvancedOptions(prev => ({ ...prev, recipientName: e.target.value }))} + placeholder="John Doe" + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500" + /> +
+
+
+ + {/* Webhook Section */} +
+
+ + Webhook Notification +
+ +
+ + + setAdvancedOptions(prev => ({ ...prev, webhookUrl: e.target.value }))} + placeholder="https://your-server.com/webhook" + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500" + /> + +

+ Receive a POST request when the credential is claimed. +

+
+
+ + {/* Suppress Delivery */} +
+ + +

+ Don't send an email — get the claim URL to use in your own system. +

+
+
+ )} + + {/* Code output */} + + + {/* Verification Polling Section */} +
+
+
+ +
+ +
+

Verify Your Code Worked

+ +

+ Run your code, then click below to verify the credential was sent successfully. +

+ + {!isPolling && !pollResult?.success && ( + + )} + + {isPolling && ( +
+ + +
+

Waiting for new credentials...

+

Run your code now. We'll detect when it's sent.

+
+ + +
+ )} + + {pollResult && ( +
+ {pollResult.success ? ( + + ) : ( + + )} + +
+

+ {pollResult.message} +

+
+ + {!pollResult.success && ( + + )} +
+ )} + + {!apiToken && ( +

+ ⚠️ Create an API token in step 1 to enable verification. +

+ )} +
+
+
+ + {/* Navigation */} +
+ + + +
+
+ ); +}; + +// Step 4: Issue & Verify +const IssueVerifyStep: React.FC<{ + onBack: () => void; +}> = ({ onBack }) => { + const [verifyInput, setVerifyInput] = useState(''); + const [verifying, setVerifying] = useState(false); + const [verifyResult, setVerifyResult] = useState<{ success: boolean; message: string } | null>(null); + + const { initWallet } = useWallet(); + + const handleVerify = async () => { + if (!verifyInput.trim()) return; + + setVerifying(true); + setVerifyResult(null); + + try { + const wallet = await initWallet(); + const credential = JSON.parse(verifyInput); + + const result = await wallet.invoke.verifyCredential(credential); + + if (result.warnings.length === 0 && result.errors.length === 0) { + setVerifyResult({ success: true, message: 'Credential is valid!' }); + } else { + const issues = [...result.warnings, ...result.errors].join(', '); + setVerifyResult({ success: false, message: `Issues found: ${issues}` }); + } + } catch (err) { + console.error('Verification failed:', err); + setVerifyResult({ success: false, message: 'Invalid credential format' }); + } finally { + setVerifying(false); + } + }; + + return ( +
+
+

Issue & Verify

+ +

+ Run your code to issue a credential, then paste it here to verify it works correctly. +

+
+ + {/* Success state */} +
+
+ +
+ +

You're all set!

+ +

+ Run the code from the previous step in your application to issue your first credential. +

+ +
+ + API Token ready +
+ +
+ + Signing authority configured +
+
+ + {/* Verification tool */} + {/*
+
+

Test Verification

+ +

Paste a credential to verify it

+
+ +
+