feat: add fancy QR codes with abacus logo throughout app

Create AbacusQRCode component that wraps QRCodeSVG with:
- Abacus logo in center (smart sizing - only on QR codes ≥150px)
- Fancy rounded dots instead of squares (qrStyle="dots")
- High error correction (level="H") for better scanning with logo
- Logo size scales proportionally (22% of QR code size)

Update all QR code usages to use AbacusQRCode:
- ShareModal: Worksheet sharing (220px with logo)
- QRCodeButton: Room join codes (84px no logo, 200px with logo)
- QRCodeDisplay: Worksheet upload (200px with logo)

Fix shared worksheet viewer Share button:
- Now uses same ShareModal as editor (was basic alert)
- Shows fancy QR code with proper styling
- Consistent UX across editor and viewer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-13 13:04:06 -06:00
parent ee1c870db1
commit ebcabf9bb9
7 changed files with 154 additions and 58 deletions

View File

@ -1,9 +1,36 @@
{
"permissions": {
"allow": ["WebFetch(domain:github.com)", "WebFetch(domain:react-resizable-panels.vercel.app)"],
"allow": [
"WebFetch(domain:github.com)",
"WebFetch(domain:react-resizable-panels.vercel.app)",
"Bash(gh run watch:*)",
"Bash(npm run build:*)",
"Bash(NODE_ENV=production npm run build:*)",
"Bash(npx @pandacss/dev:*)",
"Bash(npm run build-storybook:*)",
"Bash(ssh nas.home.network:*)",
"Bash(python3:*)",
"Bash(curl:*)",
"WebSearch",
"WebFetch(domain:community.home-assistant.io)",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:www.google.com)",
"Bash(gcloud auth list:*)",
"Bash(gcloud auth login:*)",
"Bash(gcloud projects list:*)",
"Bash(gcloud projects create:*)",
"Bash(gcloud config set:*)",
"Bash(gcloud services enable:*)",
"Bash(gcloud alpha services api-keys create:*)",
"Bash(gcloud components install:*)",
"Bash(chmod:*)",
"Bash(./fetch-streetview.sh:*)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": ["sqlite"]
"enabledMcpjsonServers": [
"sqlite"
]
}

View File

@ -2,8 +2,8 @@
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import QRCode from 'qrcode'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { AbacusQRCode } from '@/components/common/AbacusQRCode'
import type { WorksheetFormState } from '../types'
import { extractConfigFields } from '../utils/extractConfigFields'
@ -27,7 +27,6 @@ export function ShareModal({
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState<string>('')
const [copied, setCopied] = useState(false)
const qrCanvasRef = useRef<HTMLCanvasElement>(null)
// Auto-generate share link when modal opens
useEffect(() => {
@ -71,27 +70,6 @@ export function ShareModal({
generateShare()
}, [isOpen, worksheetType, config])
// Generate QR code when URL is available
useEffect(() => {
if (!shareUrl || !qrCanvasRef.current) return
QRCode.toCanvas(
qrCanvasRef.current,
shareUrl,
{
width: 200,
margin: 2,
color: {
dark: isDark ? '#ffffff' : '#000000',
light: isDark ? '#1f2937' : '#ffffff',
},
},
(error) => {
if (error) console.error('QR Code generation error:', error)
}
)
}, [shareUrl, isDark])
if (!isOpen) return null
const handleCopy = async () => {
@ -195,7 +173,7 @@ export function ShareModal({
</div>
) : shareUrl ? (
<div className={stack({ gap: '4' })}>
{/* QR Code */}
{/* QR Code with Abacus Logo */}
<div
className={css({
display: 'flex',
@ -205,14 +183,19 @@ export function ShareModal({
>
<div
className={css({
bg: isDark ? 'gray.700' : 'white',
p: '4',
rounded: 'lg',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
bg: 'white',
p: '5',
rounded: 'xl',
border: '3px solid',
borderColor: 'brand.400',
boxShadow: '0 4px 16px rgba(251, 146, 60, 0.2)',
})}
>
<canvas ref={qrCanvasRef} />
<AbacusQRCode
value={shareUrl}
size={220}
fgColor={isDark ? '#1f2937' : '#111827'}
/>
</div>
</div>

View File

@ -62,7 +62,7 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
//
// Get primary state values (source of truth for calculation)
const problemsPerPage = formState.problemsPerPage ?? (formState.total ?? 20)
const problemsPerPage = formState.problemsPerPage ?? formState.total ?? 20
const pages = formState.pages ?? 1
// Calculate derived state: total = problemsPerPage × pages

View File

@ -5,6 +5,7 @@ import { stack } from '@styled/patterns'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useState, useRef } from 'react'
import { PreviewCenter } from '@/app/create/worksheets/components/PreviewCenter'
import { ShareModal } from '@/app/create/worksheets/components/ShareModal'
import { WorksheetConfigProvider } from '@/app/create/worksheets/components/WorksheetConfigContext'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { PageWithNav } from '@/components/PageWithNav'
@ -34,6 +35,7 @@ export default function SharedWorksheetPage() {
const [previewError, setPreviewError] = useState<{ error: string; details?: string } | null>(null)
const [preview, setPreview] = useState<string[] | undefined>(undefined)
const [showEditModal, setShowEditModal] = useState(false)
const [showShareModal, setShowShareModal] = useState(false)
// Track if we've already fetched to prevent duplicate API calls in StrictMode
const hasFetchedRef = useRef(false)
@ -401,29 +403,22 @@ export default function SharedWorksheetPage() {
}}
status="idle"
isReadOnly={true}
onShare={async () => {
// Create a new share link for this config
const response = await fetch('/api/worksheets/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worksheetType: 'addition',
config: shareData.config,
}),
})
if (response.ok) {
const data = await response.json()
await navigator.clipboard.writeText(data.url)
// TODO: Show toast notification
alert('Share link copied to clipboard!')
}
}}
onShare={() => setShowShareModal(true)}
onEdit={() => setShowEditModal(true)}
/>
)
}
/>
{/* Share Modal */}
<ShareModal
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
worksheetType="addition"
config={shareData.config}
isDark={isDark}
/>
{/* Edit Modal */}
{showEditModal && (
<div

View File

@ -0,0 +1,93 @@
import { QRCodeSVG } from 'qrcode.react'
import type { QRCodeSVGProps } from 'qrcode.react'
export interface AbacusQRCodeProps extends Omit<QRCodeSVGProps, 'imageSettings'> {
/**
* Override the default abacus logo with a custom image
* If not provided, uses the abacus icon at /icon (only on QR codes >= 150px)
*/
imageSettings?: QRCodeSVGProps['imageSettings']
/**
* Minimum size (in pixels) to show the logo
* Below this threshold, QR code will be plain for better scannability
* @default 150
*/
minLogoSize?: number
}
/**
* QR Code component with the abacus logo in the middle
*
* This is a thin wrapper around QRCodeSVG that adds our branding by default.
* Use this as the standard QR code component throughout the app.
*
* **Smart logo sizing:** The abacus logo only appears on QR codes >= 150px.
* Smaller QR codes show plain dots for better scannability.
*
* @example
* ```tsx
* // Large QR code - shows abacus logo
* <AbacusQRCode
* value="https://abaci.one/share/abc123"
* size={200}
* />
* ```
*
* @example
* ```tsx
* // Small QR code - no logo (too small)
* <AbacusQRCode
* value="https://abaci.one/room/xyz"
* size={84}
* />
* ```
*
* @example With custom styling
* ```tsx
* <AbacusQRCode
* value="https://abaci.one/room/xyz"
* size={300}
* qrStyle="dots" // Fancy rounded dots
* fgColor="#1a1a2e"
* level="H" // High error correction for better scanning with logo
* />
* ```
*/
export function AbacusQRCode({
imageSettings,
minLogoSize = 150,
size = 128,
level = 'H', // Default to high error correction for logo
qrStyle = 'dots', // Default to fancy rounded dots
fgColor = '#111827',
bgColor = '#ffffff',
...props
}: AbacusQRCodeProps) {
// Only show logo on QR codes large enough for it to scan reliably
const showLogo = typeof size === 'number' && size >= minLogoSize
// Calculate logo size as 22% of QR code size (scales nicely)
const logoSize = typeof size === 'number' ? Math.round(size * 0.22) : 48
return (
<QRCodeSVG
{...props}
size={size}
level={level}
qrStyle={qrStyle}
fgColor={fgColor}
bgColor={bgColor}
imageSettings={
showLogo
? (imageSettings ?? {
src: '/icon',
height: logoSize,
width: logoSize,
excavate: true, // Clear space behind logo for better scanning
})
: undefined // No logo on small QR codes
}
/>
)
}

View File

@ -1,9 +1,9 @@
import * as Popover from '@radix-ui/react-popover'
import type { CSSProperties } from 'react'
import { useState } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { useClipboard } from '@/hooks/useClipboard'
import { Z_INDEX } from '@/constants/zIndex'
import { AbacusQRCode } from './AbacusQRCode'
export interface QRCodeButtonProps {
/**
@ -61,7 +61,7 @@ export function QRCodeButton({ url, style }: QRCodeButtonProps) {
Object.assign(e.currentTarget.style, buttonStyles)
}}
>
<QRCodeSVG value={url} size={84} level="L" />
<AbacusQRCode value={url} size={84} />
</button>
</Popover.Trigger>
@ -105,7 +105,7 @@ export function QRCodeButton({ url, style }: QRCodeButtonProps) {
marginBottom: '16px',
}}
>
<QRCodeSVG value={url} size={200} level="H" />
<AbacusQRCode value={url} size={200} />
</div>
{/* URL with copy button */}

View File

@ -1,8 +1,8 @@
'use client'
import { QRCodeSVG } from 'qrcode.react'
import { useState } from 'react'
import { css } from '../../../styled-system/css'
import { AbacusQRCode } from '../common/AbacusQRCode'
interface QRCodeDisplayProps {
sessionId: string
@ -78,11 +78,9 @@ export function QRCodeDisplay({ sessionId, uploadCount, uploads }: QRCodeDisplay
borderColor: 'gray.200',
})}
>
<QRCodeSVG
<AbacusQRCode
value={uploadUrl}
size={200}
level="M"
includeMargin={false}
className={css({
display: 'block',
})}