feat: add BusinessCard component and related subcomponents for displaying business card information

- Implemented BusinessCard component to aggregate various card sections.
- Created CardHeader, CardProfile, CardBio, CardContactInfo, CardFooter components for structured layout.
- Added styles for each component to ensure responsive design.
- Introduced SaveContactButton for downloading vCard and QRCodeDisplay for generating QR codes.
- Configured context for managing business card data with BusinessCardProvider and BusinessCardContext.
- Added local configuration files for easy customization of business card data.
- Established types for business card and social links to ensure type safety.
- Set up TypeScript configuration for the project.
- Integrated Vite for development and build processes.
This commit is contained in:
Sascha 2026-02-13 16:03:12 +01:00
commit 629da34064
42 changed files with 5312 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

118
GIT_SETUP.md Normal file
View File

@ -0,0 +1,118 @@
# Git Setup Guide
## Pushing Your Project to Git
### Initial Setup
1. **Initialize Git** (if not already done):
```bash
git init
```
2. **Add a Remote** (replace with your repository URL):
```bash
git remote add origin https://github.com/yourusername/yourrepo.git
```
3. **Create Initial Commit**:
```bash
git add .
git commit -m "Initial commit: Virtual Business Card"
```
4. **Push to Remote**:
```bash
git push -u origin main
```
## Local Configuration Safety
✅ **What Gets Pushed to Git:**
- Default business card config: `src/config/businessCard.config.ts`
- All source code and components
- Configuration and build files
- Documentation
❌ **What Stays Local (Never Pushed):**
- Your personal business card config: `src/config/businessCard.config.local.ts`
- Node modules: `node_modules/`
- Build output: `dist/`
- Other files matching `*.local` pattern
### How `.gitignore` Protects Your Data
The `.gitignore` file already includes:
```
*.local
```
This means any file ending with `.local` is automatically excluded from git. Your personal business card configuration in `businessCard.config.local.ts` will never be tracked or pushed.
## Workflow for Sharing the Project
### For You (Developer)
1. Create `src/config/businessCard.config.local.ts` with your personal info
2. Make changes to your local config as needed
3. Push code changes to git (your personal config stays local)
### For Others Using Your Repository
1. Clone the repository
2. Copy `src/config/businessCard.config.local.example.ts` to `src/config/businessCard.config.local.ts`
3. Edit the local config with their own information
4. Run `npm install --legacy-peer-deps && npm run dev`
5. Their changes to `businessCard.config.local.ts` stay local
## Checking Git Status
To verify your personal config is being ignored:
```bash
git status
```
You should NOT see `src/config/businessCard.config.local.ts` in the output. If you do, your `.gitignore` may need adjustment.
To also check git tracking:
```bash
git check-ignore src/config/businessCard.config.local.ts
```
This should print the file path if it's properly ignored.
## Example Git Commands
```bash
# Check what will be committed
git status
# Add all non-ignored files
git add .
# Commit changes
git commit -m "Update business card styles"
# Push to remote
git push
# View git history
git log
# See what files are being tracked
git ls-files
```
## Important Notes
- Always ensure `businessCard.config.local.ts` is in `.gitignore` before pushing
- Never commit personal business card data
- The example file `businessCard.config.local.example.ts` CAN be committed to help others
- Each user/clone should have their own local config file

84
LOCAL_CONFIG_SETUP.md Normal file
View File

@ -0,0 +1,84 @@
# Local Configuration Setup
This project supports local configuration for your business card data without committing it to git.
## How to Use Local Configuration
### 1. Copy the Example File
Copy `src/config/businessCard.config.local.example.ts` and rename it to `src/config/businessCard.config.local.ts`:
```bash
cp src/config/businessCard.config.local.example.ts src/config/businessCard.config.local.ts
```
### 2. Edit Your Local Config
Open `src/config/businessCard.config.local.ts` and update all the fields with your information:
```typescript
export const localBusinessCard: BusinessCard = {
name: 'Your Name',
title: 'Your Job Title',
company: 'Your Company Name',
email: 'your.email@example.com',
phone: '+1 (555) 000-0000',
website: 'https://yourwebsite.com',
image: 'https://your-image-url.com/photo.jpg',
bio: 'Your professional bio goes here.',
socialLinks: [
{
platform: 'LinkedIn',
url: 'https://linkedin.com/in/yourprofile',
icon: 'Linkedin',
},
{
platform: 'Twitter',
url: 'https://twitter.com/yourhandle',
icon: 'Twitter',
},
{
platform: 'GitHub',
url: 'https://github.com/yourprofile',
icon: 'Github',
},
{ platform: 'Portfolio', url: 'https://yourwebsite.com', icon: 'Globe' },
],
}
```
### 3. No Additional Configuration Needed
The app will automatically detect and load your local config when it starts. No need to modify any other files!
## How It Works
- **Default Config**: `src/config/businessCard.config.ts` contains default demo data and **IS tracked by git**
- **Local Config**: `src/config/businessCard.config.local.ts` is created by you and **is NOT tracked by git** (added to `.gitignore`)
- **On Startup**: The app tries to load your local config; if not found, it falls back to the default
- **Your Data is Safe**: All your personal information stays local and never gets pushed to git
## File Structure
```
src/config/
├── businessCard.config.ts # Default config (git-tracked)
├── businessCard.config.local.example.ts # Template for you to copy
└── businessCard.config.local.ts # Your local config (git-ignored) ← Create this!
```
## Tips
- **Image URLs**: Use absolute URLs (https://...) for profile images
- **Phone Format**: Any format works - the phone number becomes a clickable tel: link
- **Social Icons**: Use the icon names from lucide-react:
- `Linkedin`, `Twitter`, `Github`, `Globe`, `Mail`, `Phone`
- **Testing Locally**: `npm run dev` will load your local config automatically
## Git Safety
The `.gitignore` file already includes `*.local` entries, so:
- ✅ Your `businessCard.config.local.ts` will NEVER be committed
- ✅ Only `businessCard.config.ts` (with demo data) is tracked by git
- ✅ You can safely add personal/sensitive information to your local config

186
README.md Normal file
View File

@ -0,0 +1,186 @@
# Virtual Business Card 🎴
A modern, responsive digital business card application built with React, TypeScript, and Vite. Perfect for sharing your professional information with a clean, professional design.
## Features
**Responsive Design** - Works beautifully on all screen sizes (mobile, tablet, desktop)
💾 **Save Contact** - Download contact information as .vcf file (vCard format)
🎨 **Modern UI** - Clean, professional design with smooth animations
⚙️ **Easy Configuration** - Simple config system with local overrides
🔒 **Privacy-First** - Local configuration stays local, never pushed to git
📱 **Mobile-Optimized** - Touch-friendly buttons and layout
## Quick Start
### Installation
```bash
npm install --legacy-peer-deps
```
### Development
```bash
npm run dev
```
The app will be available at `http://localhost:5173/`
### Build for Production
```bash
npm run build
```
## Configuration
### Using Default Configuration
The app comes with demo data in `src/config/businessCard.config.ts`. This is tracked by git and safe to use as a template.
### Using Local Configuration (Recommended)
To customize the business card with your own information:
1. Copy the example config:
```bash
cp src/config/businessCard.config.local.example.ts src/config/businessCard.config.local.ts
```
2. Edit `src/config/businessCard.config.local.ts` with your information
3. The app automatically loads your local config on startup
**Important**: `businessCard.config.local.ts` is git-ignored, so your personal data never gets pushed to git!
See [LOCAL_CONFIG_SETUP.md](./LOCAL_CONFIG_SETUP.md) for detailed instructions.
## Project Structure
```
src/
├── components/ # UI components
│ ├── BusinessCard.tsx
│ ├── SocialLinks.tsx
│ ├── BusinessCard.module.css
│ └── SocialLinks.module.css
├── config/ # Configuration files
│ ├── businessCard.config.ts # Default config (git-tracked)
│ ├── businessCard.config.local.example.ts # Template
│ └── configLoader.ts # Config loading logic
├── store/ # State management
│ ├── BusinessCardContext.tsx
│ ├── BusinessCardProvider.tsx
│ └── types.ts
├── App.tsx
├── App.css
├── main.tsx
└── index.css
```
## Available Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
- `npm run lint` - Run ESLint
## Technology Stack
- **React 19** - UI library
- **TypeScript** - Type safety
- **Vite 7** - Build tool
- **Lucide React** - Icon library
- **CSS Modules** - Component styling
## Customization
### Business Card Data
Edit `src/config/businessCard.config.local.ts`:
```typescript
export const localBusinessCard: BusinessCard = {
name: 'Your Name',
title: 'Your Title',
company: 'Your Company',
email: 'your.email@example.com',
phone: '+1 (555) 000-0000',
website: 'https://yourwebsite.com',
image: 'https://your-image-url.com/photo.jpg',
bio: 'Your professional bio',
socialLinks: [
{
platform: 'LinkedIn',
url: 'https://linkedin.com/in/you',
icon: 'Linkedin',
},
{ platform: 'Twitter', url: 'https://twitter.com/you', icon: 'Twitter' },
// ... more links
],
}
```
### Styling
All components use CSS Modules. Edit the respective `.module.css` files:
- `src/components/BusinessCard.module.css` - Card styling
- `src/components/SocialLinks.module.css` - Social icons styling
- `src/App.css` - App-level styling
- `src/index.css` - Global styles
## Responsive Breakpoints
The card adapts to all screen sizes:
- **320px - 479px** - Extra small (phones)
- **480px - 767px** - Small (large phones, small tablets)
- **768px - 1023px** - Medium (tablets)
- **1024px+** - Large (desktops)
## Browser Support
- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
## License
MIT
## Contributing
Feel free to fork and customize this project for your needs!
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
```

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>virtualbusinesscard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3378
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "virtualbusinesscard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.408.0",
"qrcode.react": "^1.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

57
src/App.css Normal file
View File

@ -0,0 +1,57 @@
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
.app-container {
width: 100%;
min-height: 100vh;
padding: 0;
margin: 0;
background: linear-gradient(135deg, #e0e7ef 0%, #91c5e0 100%);
display: flex;
flex-direction: column;
}
.content-wrapper {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 12px;
width: 100%;
}
/* Extra Small Screens (320px - 479px) */
@media (max-width: 479px) {
.content-wrapper {
padding: 12px;
min-height: 100vh;
}
}
/* Small Screens (480px - 767px) */
@media (min-width: 480px) and (max-width: 767px) {
.content-wrapper {
padding: 16px;
min-height: 100vh;
}
}
/* Medium Screens (768px - 1023px) */
@media (min-width: 768px) and (max-width: 1023px) {
.content-wrapper {
padding: 24px;
min-height: 100vh;
}
}
/* Large Screens (1024px+) */
@media (min-width: 1024px) {
.content-wrapper {
padding: 40px;
min-height: 100vh;
}
}

14
src/App.tsx Normal file
View File

@ -0,0 +1,14 @@
import { BusinessCard } from './components/BusinessCard';
import './App.css';
function App() {
return (
<div className="app-container">
<div className="content-wrapper">
<BusinessCard />
</div>
</div>
);
}
export default App;

BIN
src/assets/sascha.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@ -0,0 +1,132 @@
.cardContainer {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 0 12px;
}
.card {
width: 100%;
max-width: 420px;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-8px);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
}
.socialSection {
padding: 0 20px 20px;
}
.actionSection {
padding: 0 20px 20px;
}
.divider {
height: 1px;
background: #e2e8f0;
margin: 0 20px;
}
/* Extra Small Screens (320px - 479px) */
@media (max-width: 479px) {
.cardContainer {
padding: 0 8px;
}
.card {
max-width: 100%;
border-radius: 12px;
}
.socialSection {
padding: 0 16px 16px;
}
.actionSection {
padding: 0 16px 16px;
}
.divider {
margin: 0 16px;
}
}
/* Small Screens (480px - 767px) */
@media (min-width: 480px) and (max-width: 767px) {
.cardContainer {
padding: 0 10px;
}
.card {
max-width: 360px;
border-radius: 14px;
}
.socialSection {
padding: 0 18px 18px;
}
.actionSection {
padding: 0 18px 18px;
}
.divider {
margin: 0 18px;
}
}
/* Medium Screens (768px - 1023px) */
@media (min-width: 768px) and (max-width: 1023px) {
.cardContainer {
padding: 0 15px;
}
.card {
max-width: 390px;
}
.socialSection {
padding: 0 22px 22px;
}
.actionSection {
padding: 0 22px 22px;
}
.divider {
margin: 0 22px;
}
}
/* Large Screens (1024px+) */
@media (min-width: 1024px) {
.cardContainer {
padding: 0 20px;
}
.card {
max-width: 420px;
}
.socialSection {
padding: 0 20px 20px;
}
.actionSection {
padding: 0 20px 20px;
}
.divider {
margin: 0 20px;
}
}

View File

@ -0,0 +1,37 @@
import { useBusinessCard } from '../store/BusinessCardContext';
import { CardHeader } from './Card/CardHeader';
import { CardProfile } from './Card/CardProfile';
import { CardBio } from './Card/CardBio';
import { CardContactInfo } from './Card/CardContactInfo';
import { SocialLinks } from './SocialLinks';
import { SaveContactButton } from './SaveContactButton';
import { CardFooter } from './Card/CardFooter';
import styles from './BusinessCard.module.css';
export function BusinessCard() {
const { businessCard } = useBusinessCard();
return (
<div className={styles.cardContainer}>
<div className={styles.card}>
<CardHeader />
<CardProfile businessCard={businessCard} />
<CardBio bio={businessCard.bio} />
<CardContactInfo businessCard={businessCard} />
{businessCard.socialLinks.length > 0 && (
<div className={styles.socialSection}>
<SocialLinks links={businessCard.socialLinks} />
</div>
)}
<div className={styles.actionSection}>
<SaveContactButton businessCard={businessCard} />
</div>
<div className={styles.divider} />
<CardFooter companyName={businessCard.company} personName={businessCard.name} />
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
.bio {
padding: 0 20px;
margin: 0 0 20px;
font-size: 13px;
color: #4a5568;
line-height: 1.6;
font-style: italic;
}
/* Extra Small Screens (320px - 479px) */
@media (max-width: 479px) {
.bio {
padding: 0 16px;
margin: 0 0 16px;
font-size: 12px;
}
}
/* Small Screens (480px - 767px) */
@media (min-width: 480px) and (max-width: 767px) {
.bio {
padding: 0 18px;
margin: 0 0 18px;
font-size: 12px;
}
}
/* Medium Screens (768px - 1023px) */
@media (min-width: 768px) and (max-width: 1023px) {
.bio {
padding: 0 22px;
margin: 0 0 22px;
font-size: 13px;
}
}
/* Large Screens (1024px+) */
@media (min-width: 1024px) {
.bio {
padding: 0 20px;
margin: 0 0 20px;
font-size: 13px;
}
}

View File

@ -0,0 +1,11 @@
import styles from './CardBio.module.css';
interface CardBioProps {
bio: string;
}
export function CardBio({ bio }: CardBioProps) {
if (!bio) return null;
return <p className={styles.bio}>{bio}</p>;
}

View File

@ -0,0 +1,111 @@
.contactSection {
padding: 0 20px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.contactItem {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: 8px;
background: #f7fafc;
transition: background 0.2s ease;
}
.contactItem:hover {
background: #edf2f7;
}
.icon {
color: #667eea;
flex-shrink: 0;
min-width: 18px;
}
.link {
color: #2d3748;
text-decoration: none;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.link:hover {
color: #667eea;
text-decoration: underline;
}
/* Extra Small Screens (320px - 479px) */
@media (max-width: 479px) {
.contactSection {
padding: 0 16px 16px;
gap: 10px;
}
.contactItem {
padding: 8px;
gap: 10px;
}
.icon {
width: 16px;
height: 16px;
}
.link {
font-size: 12px;
}
}
/* Small Screens (480px - 767px) */
@media (min-width: 480px) and (max-width: 767px) {
.contactSection {
padding: 0 18px 18px;
gap: 11px;
}
.contactItem {
padding: 9px;
}
.link {
font-size: 12px;
}
}
/* Medium Screens (768px - 1023px) */
@media (min-width: 768px) and (max-width: 1023px) {
.contactSection {
padding: 0 22px 22px;
gap: 11px;
}
.contactItem {
padding: 10px;
}
.link {
font-size: 13px;
}
}
/* Large Screens (1024px+) */
@media (min-width: 1024px) {
.contactSection {
padding: 0 20px 20px;
gap: 12px;
}
.contactItem {
padding: 10px;
}
.link {
font-size: 13px;
}
}

View File

@ -0,0 +1,39 @@
import { Mail, Phone, Globe } from 'lucide-react';
import type { BusinessCard } from '../../store/types';
import styles from './CardContactInfo.module.css';
interface CardContactInfoProps {
businessCard: BusinessCard;
}
export function CardContactInfo({ businessCard }: CardContactInfoProps) {
return (
<div className={styles.contactSection}>
<div className={styles.contactItem}>
<Mail size={18} className={styles.icon} />
<a href={`mailto:${businessCard.email}`} className={styles.link}>
{businessCard.email}
</a>
</div>
<div className={styles.contactItem}>
<Phone size={18} className={styles.icon} />
<a href={`tel:${businessCard.phone}`} className={styles.link}>
{businessCard.phone}
</a>
</div>
{businessCard.website && (
<div className={styles.contactItem}>
<Globe size={18} className={styles.icon} />
<a
href={businessCard.website}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
{businessCard.website.replace('https://', '').replace('http://', '')}
</a>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,41 @@
.footer {
padding: 15px 20px;
text-align: center;
background: #f7fafc;
font-size: 11px;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Extra Small Screens (320px - 479px) */
@media (max-width: 479px) {
.footer {
padding: 12px 16px;
font-size: 10px;
}
}
/* Small Screens (480px - 767px) */
@media (min-width: 480px) and (max-width: 767px) {
.footer {
padding: 13px 18px;
font-size: 10px;
}
}
/* Medium Screens (768px - 1023px) */
@media (min-width: 768px) and (max-width: 1023px) {
.footer {
padding: 14px 22px;
font-size: 11px;
}
}
/* Large Screens (1024px+) */
@media (min-width: 1024px) {
.footer {
padding: 15px 20px;
font-size: 11px;
}
}

View File

@ -0,0 +1,14 @@
import styles from './CardFooter.module.css';
interface CardFooterProps {
companyName: string;
personName: string;
}
export function CardFooter({ personName }: Readonly<CardFooterProps>) {
return (
<div className={styles.footer}>
<p>© 2026 {personName}</p>
</div>
);
}

View File

@ -0,0 +1,33 @@
.header {
height: 120px;
background: linear-gradient(135deg, #e0e7ef 0%, #91c5e0 100%);
position: relative;
}
/* Extra Small Screens (320px - 479px) */
@media (max-width: 479px) {
.header {
height: 90px;
}
}
/* Small Screens (480px - 767px) */
@media (min-width: 480px) and (max-width: 767px) {
.header {
height: 105px;
}
}
/* Medium Screens (768px - 1023px) */
@media (min-width: 768px) and (max-width: 1023px) {
.header {
height: 115px;
}
}
/* Large Screens (1024px+) */
@media (min-width: 1024px) {
.header {
height: 120px;
}
}

View File

@ -0,0 +1,5 @@
import styles from './CardHeader.module.css';
export function CardHeader() {
return <div className={styles.header} />;
}

View File

@ -0,0 +1,164 @@
.profileSection {
padding: 40px 20px 20px;
text-align: center;
position: relative;
}
.profileImage {
width: 100px;
height: 100px;
border-radius: 50%;
border: 5px solid white;
object-fit: cover;
margin-top: -70px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
transition: transform 0.3s ease;
}
.profileImage:hover {
transform: scale(1.05);
}
.name {
font-size: 24px;
font-weight: 700;
color: #1a202c;
margin: 15px 0 5px;
letter-spacing: -0.5px;
word-break: break-word;
}
.title {
font-size: 14px;
font-weight: 600;
color: #667eea;
margin: 5px 0;
text-transform: uppercase;
letter-spacing: 1px;
word-break: break-word;
}
.company {
font-size: 13px;
color: #718096;
margin: 5px 0 15px;
font-weight: 500;
word-break: break-word;
}
/* Extra Small Screens (320px - 479px) */
@media (max-width: 479px) {
.profileSection {
padding: 35px 16px 16px;
}
.profileImage {
width: 75px;
height: 75px;
border-width: 4px;
margin-top: -50px;
}
.name {
font-size: 18px;
margin: 12px 0 4px;
}
.title {
font-size: 12px;
margin: 3px 0;
letter-spacing: 0.5px;
}
.company {
font-size: 12px;
margin: 3px 0 12px;
}
}
/* Small Screens (480px - 767px) */
@media (min-width: 480px) and (max-width: 767px) {
.profileSection {
padding: 37px 18px 18px;
}
.profileImage {
width: 85px;
height: 85px;
border-width: 4px;
margin-top: -58px;
}
.name {
font-size: 20px;
margin: 13px 0 4px;
}
.title {
font-size: 12px;
margin: 3px 0;
}
.company {
font-size: 12px;
margin: 3px 0 13px;
}
}
/* Medium Screens (768px - 1023px) */
@media (min-width: 768px) and (max-width: 1023px) {
.profileSection {
padding: 39px 22px 22px;
}
.profileImage {
width: 95px;
height: 95px;
border-width: 5px;
margin-top: -65px;
}
.name {
font-size: 22px;
margin: 14px 0 5px;
}
.title {
font-size: 13px;
margin: 4px 0;
}
.company {
font-size: 12px;
margin: 4px 0 14px;
}
}
/* Large Screens (1024px+) */
@media (min-width: 1024px) {
.profileSection {
padding: 40px 20px 20px;
}
.profileImage {
width: 100px;
height: 100px;
border-width: 5px;
margin-top: -70px;
}
.name {
font-size: 24px;
margin: 15px 0 5px;
}
.title {
font-size: 14px;
margin: 5px 0;
}
.company {
font-size: 13px;
margin: 5px 0 15px;
}
}

View File

@ -0,0 +1,21 @@
import type { BusinessCard } from '../../store/types';
import styles from './CardProfile.module.css';
interface CardProfileProps {
businessCard: BusinessCard;
}
export function CardProfile({ businessCard }: Readonly<CardProfileProps>) {
return (
<div className={styles.profileSection}>
<img
src={businessCard.image}
alt={businessCard.name}
className={styles.profileImage}
/>
<h1 className={styles.name}>{businessCard.name}</h1>
<p className={styles.title}>{businessCard.title}</p>
<p className={styles.company}>{businessCard.company}</p>
</div>
);
}

View File

@ -0,0 +1,93 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 40px 30px;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 350px;
height: fit-content;
}
.title {
font-size: 20px;
font-weight: 700;
color: #1a202c;
margin: 0;
text-align: center;
}
.qrWrapper {
padding: 15px;
background: white;
border: 2px solid #e2e8f0;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
transition:
border-color 0.3s ease,
box-shadow 0.3s ease;
}
.qrWrapper:hover {
border-color: #667eea;
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
}
.qrWrapper canvas {
width: 256px !important;
height: 256px !important;
}
.subtitle {
font-size: 12px;
color: #718096;
margin: 0;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.downloadBtn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 12px 20px;
background: linear-gradient(135deg, #3f51b5 0%, #5b2c83 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.downloadBtn:hover {
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
transform: translateY(-2px);
}
.downloadBtn:active {
transform: translateY(0);
}
@media (max-width: 768px) {
.container {
max-width: 100%;
padding: 30px 20px;
}
.qrWrapper canvas {
width: 200px !important;
height: 200px !important;
}
}

View File

@ -0,0 +1,65 @@
import { useRef } from 'react';
// @ts-expect-error - qrcode.react doesn't have official TS types
import QRCode from 'qrcode.react';
import { Download } from 'lucide-react';
import { useBusinessCard } from '../store/BusinessCardContext';
import styles from './QRCodeDisplay.module.css';
import type { BusinessCard } from '../store/types';
function generateVCard(businessCard: BusinessCard): string {
const lines = [
'BEGIN:VCARD',
'VERSION:3.0',
`FN:${businessCard.name}`,
`N:${businessCard.name.split(' ').reverse().join(';')}`,
`TITLE:${businessCard.title}`,
`ORG:${businessCard.company}`,
`EMAIL:${businessCard.email}`,
`TEL:${businessCard.phone}`,
businessCard.website ? `URL:${businessCard.website}` : null,
businessCard.image ? `PHOTO;VALUE=URL:${businessCard.image}` : null,
'END:VCARD',
]
.filter(Boolean)
.join('\r\n');
return lines;
}
export function QRCodeDisplay() {
const { businessCard } = useBusinessCard();
const qrRef = useRef<HTMLDivElement>(null);
const vCard = generateVCard(businessCard);
const handleDownload = () => {
const canvas = qrRef.current?.querySelector('canvas');
if (canvas) {
const link = document.createElement('a');
link.href = canvas.toDataURL('image/png');
link.download = `${businessCard.name}-contact.png`;
link.click();
}
};
return (
<div className={styles.container}>
<h2 className={styles.title}>Scan to Add Contact</h2>
<div className={styles.qrWrapper} ref={qrRef}>
<QRCode
value={vCard}
size={256}
level="H"
includeMargin
fgColor="#000000"
bgColor="#ffffff"
/>
</div>
<p className={styles.subtitle}>Scan with any smartphone camera</p>
<button onClick={handleDownload} className={styles.downloadBtn} aria-label="Download QR code">
<Download size={20} />
<span>Download QR Code</span>
</button>
</div>
);
}

View File

@ -0,0 +1,65 @@
.saveButton {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px 20px;
background: linear-gradient(135deg, #e0e7ef 0%, #91c5e0 100%);
color: black;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
min-height: 44px;
}
.saveButton:hover {
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
transform: translateY(-2px);
}
.saveButton:active {
transform: translateY(0);
}
/* Extra Small Screens (320px - 479px) */
@media (max-width: 479px) {
.saveButton {
padding: 10px 16px;
font-size: 12px;
gap: 8px;
min-height: 40px;
}
}
/* Small Screens (480px - 767px) */
@media (min-width: 480px) and (max-width: 767px) {
.saveButton {
padding: 11px 18px;
font-size: 13px;
min-height: 42px;
}
}
/* Medium Screens (768px - 1023px) */
@media (min-width: 768px) and (max-width: 1023px) {
.saveButton {
padding: 12px 22px;
font-size: 14px;
min-height: 43px;
}
}
/* Large Screens (1024px+) */
@media (min-width: 1024px) {
.saveButton {
padding: 12px 20px;
font-size: 14px;
min-height: 44px;
}
}

View File

@ -0,0 +1,47 @@
import { Download } from 'lucide-react';
import type { BusinessCard } from '../store/types';
import styles from './SaveContactButton.module.css';
interface SaveContactButtonProps {
businessCard: BusinessCard;
}
function generateVCard(businessCard: BusinessCard): string {
const lines = [
'BEGIN:VCARD',
'VERSION:3.0',
`FN:${businessCard.name}`,
`N:${businessCard.name.split(' ').reverse().join(';')}`,
`TITLE:${businessCard.title}`,
`ORG:${businessCard.company}`,
`EMAIL:${businessCard.email}`,
`TEL:${businessCard.phone}`,
businessCard.website ? `URL:${businessCard.website}` : null,
businessCard.image ? `PHOTO;VALUE=URL:${businessCard.image}` : null,
'END:VCARD',
]
.filter(Boolean)
.join('\r\n');
return lines;
}
export function SaveContactButton({ businessCard }: Readonly<SaveContactButtonProps>) {
const handleSaveContact = () => {
const vCard = generateVCard(businessCard);
const blob = new Blob([vCard], { type: 'text/vcard;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${businessCard.name}.vcf`;
link.click();
URL.revokeObjectURL(url);
};
return (
<button onClick={handleSaveContact} className={styles.saveButton} aria-label="Save contact">
<Download size={20} />
<span>Save Contact</span>
</button>
);
}

View File

@ -0,0 +1,89 @@
.container {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.link {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: #f7fafc;
color: #3b4cca;
transition: all 0.3s ease;
text-decoration: none;
border: 2px solid transparent;
min-width: 40px;
min-height: 40px;
}
.link:hover {
background: #3b4cca;
color: white;
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.link:active {
transform: translateY(-1px);
}
/* Extra Small Screens (320px - 479px) */
@media (max-width: 479px) {
.container {
gap: 10px;
}
.link {
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
}
}
/* Small Screens (480px - 767px) */
@media (min-width: 480px) and (max-width: 767px) {
.container {
gap: 11px;
}
.link {
width: 38px;
height: 38px;
min-width: 38px;
min-height: 38px;
}
}
/* Medium Screens (768px - 1023px) */
@media (min-width: 768px) and (max-width: 1023px) {
.container {
gap: 12px;
}
.link {
width: 39px;
height: 39px;
min-width: 39px;
min-height: 39px;
}
}
/* Large Screens (1024px+) */
@media (min-width: 1024px) {
.container {
gap: 12px;
}
.link {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
}
}

View File

@ -0,0 +1,36 @@
import { Github, Linkedin, Twitter, Globe, Mail, Phone } from 'lucide-react';
import type { SocialLink } from '../store/types';
import styles from './SocialLinks.module.css';
interface SocialLinksProps {
links: SocialLink[];
}
const iconMap: Record<string, React.ReactNode> = {
Github: <Github size={20} />,
Linkedin: <Linkedin size={20} />,
Twitter: <Twitter size={20} />,
Globe: <Globe size={20} />,
Mail: <Mail size={20} />,
Phone: <Phone size={20} />,
};
export function SocialLinks({ links }: Readonly<SocialLinksProps>) {
return (
<div className={styles.container}>
{links.map((link) => (
<a
key={link.platform}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
title={link.platform}
aria-label={`Visit ${link.platform}`}
>
{iconMap[link.icon] || <Globe size={20} />}
</a>
))}
</div>
);
}

View File

@ -0,0 +1,30 @@
import type { BusinessCard } from '../store/types';
/**
* LOCAL CONFIGURATION FILE
*
* To use this file:
* 1. Copy this file and rename it to: businessCard.config.local.ts
* 2. Update the values below with your information
* 3. The local config will be loaded automatically (this file is git-ignored)
* 4. Your changes will NOT be pushed to git
*
* This file is in .gitignore, so it's safe to add personal/sensitive information here.
*/
export const localBusinessCard: BusinessCard = {
name: 'Your Name',
title: 'Your Job Title',
company: 'Your Company Name',
email: 'your.email@example.com',
phone: '+1 (555) 000-0000',
website: 'https://yourwebsite.com',
image: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop',
bio: 'Your professional bio goes here.',
socialLinks: [
{ platform: 'LinkedIn', url: 'https://linkedin.com/in/yourprofile', icon: 'Linkedin' },
{ platform: 'Twitter', url: 'https://twitter.com/yourhandle', icon: 'Twitter' },
{ platform: 'GitHub', url: 'https://github.com/yourprofile', icon: 'Github' },
{ platform: 'Portfolio', url: 'https://yourwebsite.com', icon: 'Globe' },
],
};

View File

@ -0,0 +1,32 @@
import type { BusinessCard } from '../store/types';
import businessCardImage from '../assets/sascha.png';
/**
* LOCAL STUB FILE
*
* This file serves as a stub for local configuration.
* To use your own configuration:
*
* 1. Copy businessCard.config.local.example.ts
* 2. Rename the copy to businessCard.config.local.ts (replace this file)
* 3. Edit it with your personal information
* 4. This file is git-ignored, so your data stays local!
*
* For now, it just exports the default config.
*/
export const localBusinessCard: BusinessCard = {
name: 'Sascha Bach',
title: 'Web Engineer',
company: 'Freelancer',
email: 'freelancer@sascha-bach.de',
phone: '+49 179 9364867',
website: 'https://sascha-bach.de',
image: businessCardImage,
bio: 'Passionate about Webapps and Accessibility',
socialLinks: [
{ platform: 'LinkedIn', url: 'https://linkedin.com/in/saschabach', icon: 'Linkedin' },
{ platform: 'GitHub', url: 'https://codeberg.org/saschab', icon: 'Github' },
{ platform: 'Portfolio', url: 'https://sascha-bach.de', icon: 'Globe' },
],
};

View File

@ -0,0 +1,22 @@
import type { BusinessCard } from '../store/types';
// Default business card configuration
// To customize locally without pushing to git, copy businessCard.config.local.example.ts
// to businessCard.config.local.ts and modify it
export const defaultBusinessCard: BusinessCard = {
name: 'John sas',
title: 'Senior Product Designer',
company: 'Tech Innovations Inc.',
email: 'john.d@techinnovations.com',
phone: '+1 (555) 123-4567',
website: 'https://johndoe.design',
image: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop',
bio: 'Passionate about creating beautiful and functional digital experiences.',
socialLinks: [
{ platform: 'LinkedIn', url: 'https://linkedin.com/in/johndoe', icon: 'Linkedin' },
{ platform: 'Twitter', url: 'https://twitter.com/johndoe', icon: 'Twitter' },
{ platform: 'GitHub', url: 'https://github.com/johndoe', icon: 'Github' },
{ platform: 'Portfolio', url: 'https://johndoe.design', icon: 'Globe' },
],
};

View File

@ -0,0 +1,42 @@
import { defaultBusinessCard } from './businessCard.config';
import { localBusinessCard } from './businessCard.config.local';
import type { BusinessCard } from '../store/types';
/**
* Loads the business card configuration.
*
* How it works:
* - businessCard.config.ts: Contains demo/default data (git-tracked)
* - businessCard.config.local.ts: Your personal config (git-ignored)
*
* To customize with your own data:
* 1. Edit src/config/businessCard.config.local.ts
* 2. Replace the stub content with your information
* 3. The app will automatically load your config
* 4. Your changes are safe - this file is git-ignored!
*/
export function getBusinessCardConfig(): BusinessCard {
// If local config has been customized (not just the default stub), use it
// Otherwise, use the default config
// We can detect if it's been customized by checking if any values differ
if (isLocalConfigCustomized()) {
return localBusinessCard;
}
return defaultBusinessCard;
}
/**
* Checks if the local config has been customized
* A config is considered customized if it differs from the default
*/
function isLocalConfigCustomized(): boolean {
return JSON.stringify(localBusinessCard) !== JSON.stringify(defaultBusinessCard);
}
/**
* Synchronous version for initialization
*/
export function getDefaultBusinessCard(): BusinessCard {
return { ...defaultBusinessCard };
}

58
src/index.css Normal file
View File

@ -0,0 +1,58 @@
:root {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
line-height: 1.5;
font-weight: 400;
color: #1a202c;
background-color: #f5f7fa;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
#root {
width: 100%;
height: 100%;
}
a {
color: #667eea;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
font-family: inherit;
cursor: pointer;
transition: all 0.3s ease;
}
button:hover {
transform: translateY(-2px);
}
button:focus,
button:focus-visible {
outline: 2px solid #667eea;
outline-offset: 2px;
}

13
src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { BusinessCardProvider } from './store/BusinessCardProvider.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BusinessCardProvider>
<App />
</BusinessCardProvider>
</StrictMode>,
);

View File

@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
import type { BusinessCardContextType } from './types';
export const BusinessCardContext = createContext<BusinessCardContextType | undefined>(undefined);
export function useBusinessCard() {
const context = useContext(BusinessCardContext);
if (!context) {
throw new Error('useBusinessCard must be used within BusinessCardProvider');
}
return context;
}

View File

@ -0,0 +1,29 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import type { BusinessCard } from './types';
import { BusinessCardContext } from './BusinessCardContext';
import { getBusinessCardConfig } from '../config/configLoader';
interface BusinessCardProviderProps {
children: ReactNode;
}
export function BusinessCardProvider({ children }: Readonly<BusinessCardProviderProps>) {
// Load config on initialization
const [businessCard] = useState<BusinessCard>(getBusinessCardConfig());
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const updateBusinessCard = (_card: Partial<BusinessCard>) => {
// Note: Runtime updates disabled to prevent accidental data loss
// To customize your business card, edit src/config/businessCard.config.local.ts
console.warn(
'To update your business card, edit src/config/businessCard.config.local.ts'
);
};
return (
<BusinessCardContext.Provider value={{ businessCard, updateBusinessCard }}>
{children}
</BusinessCardContext.Provider>
);
}

22
src/store/types.ts Normal file
View File

@ -0,0 +1,22 @@
export interface SocialLink {
platform: string;
url: string;
icon: string;
}
export interface BusinessCard {
name: string;
title: string;
company: string;
email: string;
phone: string;
website: string;
image: string;
bio: string;
socialLinks: SocialLink[];
}
export interface BusinessCardContextType {
businessCard: BusinessCard;
updateBusinessCard: (card: Partial<BusinessCard>) => void;
}

28
tsconfig.app.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler']],
},
}),
],
})