first commit

This commit is contained in:
“dongming”
2025-12-18 12:22:21 +08:00
commit b422c43928
69 changed files with 6247 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?

150
README.md Normal file
View File

@@ -0,0 +1,150 @@
# turingflow-blog-001
个人作品集/博客网站模板,适用于设计师、开发者、自由职业者展示作品和服务。
## 技术栈
- React 19 + Vite 7
- Tailwind CSS 4
- React Router DOM 7
- Lucide React (图标库)
## 快速开始
```bash
pnpm install
pnpm run dev
```
## 目录结构
```text
src/
├── App.jsx # 应用入口,包含路由配置
├── main.jsx # React 挂载点
├── index.css # 全局样式 (Tailwind)
├── components/ # 可复用组件
│ ├── NavBar.jsx # 导航栏
│ ├── Footer.jsx # 页脚
│ ├── Button.jsx # 按钮组件
│ ├── AboutCard.jsx # 关于卡片
│ ├── ProjectCards.jsx # 项目卡片列表
│ ├── Testimonials.jsx # 客户评价
│ ├── Faq.jsx # 常见问题
│ └── ... # 其他组件
├── pages/ # 页面组件
│ ├── Home.jsx # 首页
│ ├── About.jsx # 关于页面
│ ├── Services.jsx # 服务页面
│ ├── Portfolio.jsx # 作品集列表
│ ├── ProductDetail.jsx# 作品详情
│ ├── Blog.jsx # 博客列表
│ ├── BlogDetail.jsx # 博客详情
│ └── Contact.jsx # 联系页面
├── data/ # 静态数据 (JSON)
│ ├── menu.json # 导航菜单配置
│ ├── projects.json # 作品集数据
│ ├── blog.json # 博客文章数据
│ ├── testimonials.json# 客户评价数据
│ ├── brands.json # 合作品牌数据
│ ├── certifications.json # 资质认证数据
│ ├── faq.json # 常见问题数据
│ └── work.json # 工作经历数据
└── lib/ # 工具库
└── Head.jsx # 页面头部管理
```
## 路由配置
| 路径 | 页面 | 说明 |
|------|------|------|
| `/` | Home | 首页 |
| `/about` | About | 关于我 |
| `/services` | Services | 服务介绍 |
| `/portfolio` | Portfolio | 作品集列表 |
| `/portfolio/:slug` | ProductDetail | 作品详情 |
| `/blog` | Blog | 博客列表 |
| `/blog/:slug` | BlogDetail | 博客详情 |
| `/contact` | Contact | 联系方式 |
## 数据结构
### 导航菜单 (data/menu.json)
```json
[
{ "label": "首页", "path": "/" },
{ "label": "关于", "path": "/about" },
{ "label": "服务", "path": "/services" }
]
```
### 项目数据 (data/projects.json)
```json
[
{
"id": 1,
"slug": "project-name",
"title": "项目标题",
"description": "项目描述",
"image": "/images/project.jpg",
"category": "Web Design",
"tags": ["React", "Tailwind"]
}
]
```
### 博客数据 (data/blog.json)
```json
[
{
"id": 1,
"slug": "article-slug",
"title": "文章标题",
"excerpt": "文章摘要",
"content": "文章正文内容...",
"image": "/images/blog.jpg",
"date": "2024-01-15",
"author": "作者名",
"tags": ["设计", "开发"]
}
]
```
## 常见修改任务
### 修改网站信息
1. 编辑 `index.html` 中的 `<title>` 和 meta 标签
2. 修改 `src/components/NavBar.jsx` 中的 Logo 和网站名称
3. 修改 `src/components/Footer.jsx` 中的版权信息
### 修改导航菜单
编辑 `src/data/menu.json`,添加或删除菜单项
### 添加新页面
1.`src/pages/` 创建新页面组件
2.`src/App.jsx` 的 Routes 中添加路由
### 修改样式主题
编辑 `src/index.css` 中的 CSS 变量或 Tailwind 配置
### 添加作品/博客
编辑 `src/data/projects.json``src/data/blog.json`
## 布局说明
- `PageShell` 组件统一控制页面最大宽度 (`max-w-7xl`)
- 响应式断点:`lg:px-10` (大屏增加内边距)
- 所有页面共享 NavBar 和 Footer
## 图片资源
- 存放在 `public/` 目录
- 引用路径:`/images/xxx.jpg`

29
eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

21
index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<!-- Fonts for Template -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Priscy Designs</title>
</head>
<body class="">
<div id="root" class="max-w-7xl mx-auto"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "cillaux",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev-host":"vite --host",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.14",
"lucide-react": "^0.546.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-router-dom": "^7.9.4",
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"vite": "^7.1.7"
}
}

2141
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0b7cf4" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-icon lucide-arrow-right"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>

After

Width:  |  Height:  |  Size: 288 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"><path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>

After

Width:  |  Height:  |  Size: 298 B

1
public/assets/arrow.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-arrow-out-up-right-icon lucide-square-arrow-out-up-right"><path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/><path d="m21 3-9 9"/><path d="M15 3h6v6"/></svg>

After

Width:  |  Height:  |  Size: 376 B

BIN
public/assets/blog-img.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-briefcase-business-icon lucide-briefcase-business"><path d="M12 12h.01"/><path d="M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><path d="M22 13a18.15 18.15 0 0 1-20 0"/><rect width="20" height="14" x="2" y="6" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 423 B

1
public/assets/chat.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-square-share-icon lucide-message-square-share"><path d="M12 3H4a2 2 0 0 0-2 2v16.286a.71.71 0 0 0 1.212.502l2.202-2.202A2 2 0 0 1 6.828 19H20a2 2 0 0 0 2-2v-4"/><path d="M16 3h6v6"/><path d="m16 9 6-6"/></svg>

After

Width:  |  Height:  |  Size: 419 B

1
public/assets/copy.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0a0a0a" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-facebook-icon lucide-facebook"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/></svg>

After

Width:  |  Height:  |  Size: 314 B

1
public/assets/figma.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0a0a0a" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-figma-icon lucide-figma"><path d="M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z"/><path d="M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z"/><path d="M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z"/><path d="M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z"/><path d="M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z"/></svg>

After

Width:  |  Height:  |  Size: 537 B

1
public/assets/house.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-house-icon lucide-house"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 400 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0a0a0a" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-instagram-icon lucide-instagram"><rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/></svg>

After

Width:  |  Height:  |  Size: 400 B

1
public/assets/layers.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-layers-icon lucide-layers"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>

After

Width:  |  Height:  |  Size: 525 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0a0a0a" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-linkedin-icon lucide-linkedin"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect width="4" height="12" x="2" y="9"/><circle cx="4" cy="4" r="2"/></svg>

After

Width:  |  Height:  |  Size: 397 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-square-text-icon lucide-message-square-text"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/><path d="M7 11h10"/><path d="M7 15h6"/><path d="M7 7h8"/></svg>

After

Width:  |  Height:  |  Size: 445 B

1
public/assets/pencil.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pencil-line-icon lucide-pencil-line"><path d="M13 21h8"/><path d="m15 5 4 4"/><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg>

After

Width:  |  Height:  |  Size: 426 B

1
public/assets/phone.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-phone-call-icon lucide-phone-call"><path d="M13 2a9 9 0 0 1 9 9"/><path d="M13 6a5 5 0 0 1 5 5"/><path d="M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384"/></svg>

After

Width:  |  Height:  |  Size: 530 B

1
public/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
public/assets/user.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

1
public/assets/user.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-user-round-icon lucide-square-user-round"><path d="M18 21a6 6 0 0 0-12 0"/><circle cx="12" cy="11" r="4"/><rect width="18" height="18" x="3" y="3" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 370 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://priscy-orcin.vercel.app/sitemap.xml

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

57
src/App.jsx Normal file
View File

@@ -0,0 +1,57 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import About from "./pages/About";
import Services from "./pages/Services";
import Portfolio from "./pages/Portfolio";
import Contact from "./pages/Contact";
import Blog from "./pages/Blog";
import ProductDetail from "./pages/ProductDetail";
import NavBar from "./components/NavBar";
import Footer from "./components/Footer";
import "./index.css";
import BlogDetail from "./pages/BlogDetail";
/** One shell to rule them all */
function PageShell({ children, className = "" }) {
// Pick one width and it will apply everywhere
// (feel free to change max-w-* value)
return (
<div className={`mx-auto w-full max-w-7xl px-5 lg:px-10 ${className}`}>
{children}
</div>
);
}
function App() {
return (
<BrowserRouter>
{/* Header */}
<PageShell>
<NavBar />
</PageShell>
{/* Main content */}
<main className="py-6">
<PageShell>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/services" element={<Services />} />
<Route path="/portfolio" element={<Portfolio />} />
<Route path="/portfolio/:slug" element={<ProductDetail />} />
<Route path="/contact" element={<Contact />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:slug" element={<BlogDetail />} />
</Routes>
</PageShell>
</main>
{/* Footer */}
<PageShell>
<Footer />
</PageShell>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,106 @@
import React from "react";
import aboutImage from "/assets/user.jpg";
import Button from "./Button";
import fbIcon from "/assets/facebook.svg";
import instgramIcon from "/assets/instagram.svg";
import linkedinIcon from "/assets/linkedin.svg";
import figmaIcon from "/assets/figma.svg";
export default function AboutCard() {
const socialMedia = [
{ id: 1, name: "Facebook", icon: fbIcon, link: "https://facebook.com" },
{
id: 2,
name: "Instagram",
icon: instgramIcon,
link: "https://www.instagram.com/",
},
{
id: 3,
name: "LinkedIn",
icon: linkedinIcon,
link: "https://www.linkedin.com/",
},
{ id: 4, name: "Figma", icon: figmaIcon, link: "https://figma.com" },
];
return (
<section
className="rounded-xl p-3.5 w-full flex flex-col gap-6 bg-[var(--card)] border border-[var(--border)] text-[var(--text)]"
aria-labelledby="about-heading"
>
{/* Portrait / cover media */}
<figure className="rounded-lg w-full h-72 overflow-hidden">
<img
src={aboutImage}
alt="Portrait of Priscilla Bankole-Daniel"
className="rounded-lg w-full h-full object-cover"
loading="lazy"
decoding="async"
/>
{/* Keep caption for screen readers only */}
<figcaption className="sr-only">
Priscillia Beaumont Full-Stack Developer and Product Designer
</figcaption>
</figure>
<h2 id="about-heading" className="text-2xl font-bold">
Priscillia Beaumont <span aria-hidden="true">👋</span>
</h2>
<p className="text-lg">
A passionate <strong>Full-Stack Developer</strong>{" "}
<span aria-hidden="true">🖥</span> & <strong>Product Designer</strong>{" "}
with <strong>12 years</strong> of experience across 24+ countries
worldwide.
</p>
{/* Primary actions */}
<div
className="flex flex-col lg:flex-row justify-evenly gap-2"
role="group"
aria-label="Contact actions"
>
<Button
title="Book A Call"
bgColor="bg-blue-500"
hoverBgColor="hover:bg-blue-600"
ariaLabel="Book a call with Priscilla"
/>
<Button
title="Send Email"
bgColor="bg-[var(--card)]"
hoverBgColor="hover:bg-[var(--hover)]"
textColor="text-[var(--muted)]"
ariaLabel="Send an email to Priscilla"
/>
</div>
{/* Social links as a list */}
<nav aria-label="Social links">
<ul className="flex flex-row gap-2">
{socialMedia.map((item) => (
<li key={item.id}>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="border border-gray-400 p-2 bg-white rounded-lg inline-flex items-center justify-center"
aria-label={`Open ${item.name} profile in a new tab`}
title={item.name}
>
<img
src={item.icon}
alt="" /* decorative; label is on the link */
className="h-5 w-5"
loading="lazy"
decoding="async"
/>
</a>
</li>
))}
</ul>
</nav>
</section>
);
}

View File

@@ -0,0 +1,53 @@
import React from "react";
import Brands from "./Brands";
import Testimonials from "./Testimonials";
import Certifications from "./Certifications";
import WorkTogetherSlider from "./WorkTogetherSlider";
export default function AboutDetails() {
const workProof = [
{ id: 1, title: "Years of Experience", details: "40+" },
{ id: 2, title: "Project Completed", details: "86+" },
{ id: 3, title: "Happy Client", details: "72+" },
];
return (
<div
className="col-span-2 rounded-xl p-7 flex flex-col gap-10 bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)]"
>
<div className="">
<div className="flex items-start flex-col-reverse gap-5 lg:flex-row justify-between lg:items-center">
<h2 className="text-2xl lg:text-4xl font-semibold">
Hi, This Is <span className="text-blue-600">Priscillia Beaumont</span>
</h2>
<p className="text-lg text-blue-600 font-semibold bg-gray-200 rounded-lg py-2 px-3">
Available For Hire
</p>
</div>
<span className="text-5xl">👋</span>
</div>
<p className="text-lg lg:w-[50%] lg:text-2xl">
A Passionate <strong>Full Stack Developer</strong> 🖥 &{" "}
<strong>Product Designer</strong>
having <strong>12 years</strong> of Experiences over 24+ Country
Worldwide.
</p>
<div className=" lg:w-[80%] grid grid-cols-3 my-5">
{workProof.map((work) => (
<div key={work.id}>
<p className="text-2xl lg:text-5xl font-bold">{work.details}</p>
<p className=" text-gray-500">{work.title}</p>
</div>
))}
</div>
<Brands />
<Testimonials />
<Certifications />
<WorkTogetherSlider />
</div>
);
}

42
src/components/Brands.jsx Normal file
View File

@@ -0,0 +1,42 @@
import React from "react";
import brandData from "../data/brands.json";
export default function Brands() {
const count = Array.isArray(brandData) ? brandData.length : 0;
return (
<section className="flex flex-col gap-5" aria-labelledby="brands-heading">
<h2 id="brands-heading" className="text-2xl lg:text-3xl font-semibold">
Working With 50+ Brands Worldwide
<span className="sr-only">
{" "}
currently showcasing {count} brand logos
</span>
</h2>
<ul
className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-5"
aria-label="Brand logos"
>
{brandData.map((brand) => (
<li
key={brand.name}
className="rounded-lg shadow py-4 px-2 bg-[var(--card)] border border-[var(--border)] text-[var(--text)]"
>
<figure className="flex items-center justify-center">
<img
src={brand.image}
alt={`${brand.name} logo`}
className="h-12 w-auto object-contain"
loading="lazy"
decoding="async"
/>
{/* Keep the visible name out if you want it super minimal; figcaption is optional/sr-only */}
<figcaption className="sr-only">{brand.name}</figcaption>
</figure>
</li>
))}
</ul>
</section>
);
}

17
src/components/Button.jsx Normal file
View File

@@ -0,0 +1,17 @@
import React from "react";
import { Link } from "react-router-dom";
import chatIcon from "/assets/arrow.svg";
export default function Button({ title, bgColor, hoverBgColor, textColor = "text-white" }) {
return (
<div className="">
<Link
to="/contact"
className={`flex gap-2 text-md items-center justify-center border border-gray-500 px-5 py-3 rounded-xl ${bgColor} ${hoverBgColor} ${textColor} font-medium`}
>
<p>{title}</p>
<img src={chatIcon} alt="chat icon" />
</Link>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import React from "react";
import certificateData from "../data/certifications.json";
import { Link } from "react-router-dom";
import chatIcon from "/assets/arrow-up-right.svg";
function isExternal(href = "") {
return /^https?:\/\//i.test(href);
}
export default function Certifications() {
return (
<section className="flex flex-col gap-5" aria-labelledby="certs-heading">
<h2 id="certs-heading" className="text-2xl lg:text-3xl font-semibold">
Professional Certifications
</h2>
<ul className="flex flex-col gap-5">
{certificateData.map((data) => {
const External = isExternal(data.link);
const Wrapper = External ? "a" : Link;
const wrapperProps = External
? {
href: data.link,
target: "_blank",
rel: "noopener noreferrer",
}
: { to: data.link };
return (
<li key={data.id} className="list-none">
<article
className="bg-[var(--card)] border border-[var(--border)] text-[var(--text)] p-5 grid lg:grid-cols-2 gap-10 rounded-lg"
aria-labelledby={`cert-title-${data.id}`}
>
{/* Left: logo + meta */}
<div className="flex gap-10 items-center bg-[var(--card)]">
<figure className="shrink-0 w-24 h-24 flex items-center justify-center rounded-md overflow-hidden bg-white border border-[var(--border)]">
<img
src={data.image}
alt="" /* decorative; caption carries the name */
className="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
/>
<figcaption className="sr-only">{data.title}</figcaption>
</figure>
<div>
<h3
id={`cert-title-${data.id}`}
className="text-xl font-semibold"
>
{data.title}
</h3>
{data.year && (
<p className="text-sm text-gray-500">
<span className="sr-only">Year: </span>
<time dateTime={`${data.year}-01-01`}>{data.year}</time>
</p>
)}
</div>
</div>
{/* Right: status + link */}
<div className="flex justify-between items-center">
<p className="text-sm">
<span className="sr-only">Status: </span>
{data.status}
</p>
<Wrapper
{...wrapperProps}
className="flex items-center gap-2 bg-[var(--card)] border border-[var(--border)] px-4 py-2 rounded-lg hover:bg-[var(--hover)]"
aria-label={`View certificate: ${data.title}`}
>
<span>View Certificate</span>
<img
src={chatIcon}
alt=""
aria-hidden="true"
className="w-5 h-5"
loading="lazy"
decoding="async"
/>
</Wrapper>
</div>
</article>
</li>
);
})}
</ul>
</section>
);
}

View File

@@ -0,0 +1,40 @@
import React from "react";
import ContactForm from "./ContactForm";
import Faq from "./Faq";
import WorkTogetherSlider from "./WorkTogetherSlider";
export default function ContactDetails() {
return (
<div
className="col-span-2 bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)] rounded-xl p-7 flex flex-col gap-10"
>
<div className="">
<div className="flex flex-col gap-5">
<h2 className="text-2xl lg:text-4xl font-semibold">
Let's 👋 <span className="text-blue-600">Work</span> Together
</h2>
<p className=" lg:text-xl">
I'm here to help if you're searching for a product designer to bring
your idea to life or a design partner to help take your business to
the next level.
</p>
</div>
</div>
<ContactForm />
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d83327.3714831671!2d-123.2063048519071!3d49.25781819059404!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x548673f143a94fb3%3A0xbb9196ea9b81f38b!2sVancouver%2C%20BC!5e0!3m2!1sen!2sca!4v1760727608245!5m2!1sen!2sca"
// width="600"
height="400"
// style="border:0;"
// allowfullscreen=""
loading="lazy"
// referrerpolicy="no-referrer-when-downgrade"
className="w-[100%] rounded-xl"
></iframe>
<Faq />
{/* <WorkTogetherSlider /> */}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import React from "react";
import Button from "./Button";
export default function ContactForm() {
const budgets = [
"Select budget...",
"$500",
"$1000-$2000",
"$2000-$4000",
"$5000+",
];
return (
<div
className="bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)] rounded-lg p-5"
>
<form className="flex flex-col gap-8">
{/* name and email inputs */}
<div className="grid lg:grid-cols-2 gap-5">
<div className="flex flex-col gap-3">
<label htmlFor="name" id="name">
Name
</label>
<input
type="text"
name="name"
id="name"
placeholder="Enter your name"
className="px-5 py-4 shadow rounded-lg bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)]"
/>
</div>
<div className="flex flex-col gap-3">
<label htmlFor="email" id="email">
Email
</label>
<input
type="text"
name="email"
id="email"
placeholder="Enter your email"
className="px-5 py-4 shadow rounded-lg bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)]"
/>
</div>
</div>
{/* subject and budget inputs */}
<div className="grid lg:grid-cols-2 gap-5">
<div className="flex flex-col gap-3">
<label htmlFor="subject" id="subject">
Subject
</label>
<input
type="text"
name="subject"
id="subject"
placeholder="Subject"
className="px-5 py-4 shadow rounded-lg bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)]"
/>
</div>
<div className="flex flex-col gap-3">
<label htmlFor="budget" id="budget">
Budget
</label>
<select
name="budget"
id="budget"
className="px-5 py-4 shadow rounded-lg bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)]"
>
{budgets.map((budget, i) => (
<option key={i} className="py-4 rounded-xl">
{budget}
</option>
))}
</select>
</div>
</div>
<div className="flex flex-col gap-3">
<label htmlFor="comment" id="comment">
Comment
</label>
<textarea
type="text"
name="comment"
id="comment"
placeholder="Type details about your inquiry"
cols="50"
rows="5"
className="px-5 py-4 shadow rounded-lg bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)]"
/>
</div>
<Button
bgColor="bg-blue-600"
textColor="text-white"
title="Send Message"
/>
</form>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from "react";
import workData from "../data/work.json";
export default function ExpertArea() {
return (
<section
className="bg-[var(--card)] border border-[var(--border)] text-[var(--text)] flex-1 px-5 rounded-xl shadow py-3 flex flex-col gap-5"
aria-labelledby="expert-area-heading"
>
<h2 id="expert-area-heading" className="text-2xl font-semibold py-2">
My Expert Area
</h2>
<ul className="grid grid-cols-2 lg:grid-cols-3 gap-3" role="list">
{workData.map((work) => (
<li key={work.id} className="mt-2 list-none">
<figure className="bg-gray-200 rounded-xl flex items-center justify-center p-4">
<img
src={work.image}
alt={`${work.company} logo`}
className="h-8 w-20 object-contain"
loading="lazy"
decoding="async"
/>
<figcaption className="sr-only">{work.company}</figcaption>
</figure>
<p className="text-center font-semibold">{work.company}</p>
</li>
))}
</ul>
</section>
);
}

114
src/components/Faq.jsx Normal file
View File

@@ -0,0 +1,114 @@
import { useRef, useState } from "react";
import faqData from "../data/faq.json";
import { Minus, Plus } from "lucide-react";
export default function Faq() {
const [openId, setOpenId] = useState(null); // which item is open (or null)
const btnRefs = useRef({}); // map of id -> button element
const toggle = (id) => setOpenId((curr) => (curr === id ? null : id));
const handleKeyDown = (e, idList, id) => {
const idx = idList.indexOf(id);
if (idx === -1) return;
let nextIndex = idx;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
nextIndex = (idx + 1) % idList.length;
break;
case "ArrowUp":
e.preventDefault();
nextIndex = (idx - 1 + idList.length) % idList.length;
break;
case "Home":
e.preventDefault();
nextIndex = 0;
break;
case "End":
e.preventDefault();
nextIndex = idList.length - 1;
break;
default:
return;
}
const nextId = idList[nextIndex];
btnRefs.current[nextId]?.focus();
};
// Stable list of IDs (fallback to index if JSON item lacks id)
const ids = faqData.map((item, i) => item.id ?? i);
return (
<section aria-labelledby="faq-heading">
<h2 id="faq-heading" className="text-xl lg:text-4xl font-semibold mb-4">
Frequently Asked Questions
</h2>
<ul className="flex flex-col gap-5" role="list">
{faqData.map((item, i) => {
const id = item.id ?? i;
const isOpen = openId === id;
const panelId = `faq-panel-${id}`;
const buttonId = `faq-button-${id}`;
return (
<li key={id} className="list-none">
<div
className={`${
isOpen
? "bg-[var(--card)] border border-[var(--border)] text-[var(--text)] p-6 rounded-lg"
: "p-6 bg-[var(--card)] border border-[var(--border)] text-[var(--text)] rounded-lg"
}`}
>
{/* Heading contains the toggle button (ARIA accordion pattern) */}
<h3 className="m-0">
<button
id={buttonId}
ref={(el) => (btnRefs.current[id] = el)}
onClick={() => toggle(id)}
onKeyDown={(e) => handleKeyDown(e, ids, id)}
className={`w-full flex items-center justify-between gap-3 text-left
font-medium lg:text-xl focus:outline-none focus-visible:ring
focus-visible:ring-blue-500 rounded-md px-2 py-1`}
aria-expanded={isOpen}
aria-controls={panelId}
>
<span
className={
isOpen ? "text-blue-600" : "text-[var(--text)]"
}
>
{item.question}
</span>
<span aria-hidden="true" className="shrink-0 inline-flex">
{isOpen ? (
<Minus color="#2c50e2" strokeWidth={0.75} />
) : (
<Plus color="#2c50e2" strokeWidth={0.75} />
)}
</span>
</button>
</h3>
{/* Panel (region) */}
<div
id={panelId}
role="region"
aria-labelledby={buttonId}
aria-hidden={!isOpen}
className={`transition-all duration-300 overflow-hidden ${
isOpen ? "max-h-40 mt-3" : "max-h-0"
}`}
>
<p className="mt-1">{item.answer}</p>
</div>
</div>
</li>
);
})}
</ul>
</section>
);
}

27
src/components/Footer.jsx Normal file
View File

@@ -0,0 +1,27 @@
import React from "react";
export default function Footer() {
const year = new Date().getFullYear();
return (
<footer
role="contentinfo"
className="container px-0 py-5 text-sm lg:text-lg"
aria-label="Site footer"
>
<p className="text-center">
<span className="sr-only">Copyright </span>
<span aria-hidden="true">&copy;</span> PriscyDesigns{" "}
<time dateTime={`${year}`}>{year}</time>, Design by{" "}
<a
href="https://craftedtemplate.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 font-bold underline-offset-2 hover:underline"
>
Crafted Template
</a>
</p>
</footer>
);
}

View File

@@ -0,0 +1,207 @@
import React, { useEffect, useRef, useState } from "react";
/**
* GallerySlider (a11y tuned)
* @param {Array<{src:string, alt?:string, caption?:string}>} images
* @param {number} interval ms between slides (default 4000)
* @param {boolean} loop wrap around (default true)
* @param {string} ariaLabel accessible label for the carousel (default "Image gallery")
* @param {string} imgSizes <img sizes> attr for responsive layouts (optional)
* @param {number} heightPx fixed slide height in px (default 310)
*/
export default function GallerySlider({
images = [],
interval = 4000,
loop = true,
ariaLabel = "Image gallery",
imgSizes,
heightPx = 310,
}) {
const [index, setIndex] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const [reducedMotion, setReducedMotion] = useState(false);
const timerRef = useRef(null);
const touchStartX = useRef(null);
const touchDeltaX = useRef(0);
const slideCount = images.length;
const clampIndex = (i) => {
if (slideCount === 0) return 0;
if (loop) return (i + slideCount) % slideCount;
return Math.max(0, Math.min(slideCount - 1, i));
};
const goTo = (i) => setIndex(clampIndex(i));
const next = () => goTo(index + 1);
const prev = () => goTo(index - 1);
// Respect prefers-reduced-motion
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
const update = () => setReducedMotion(!!mq?.matches);
update();
mq?.addEventListener?.("change", update);
return () => mq?.removeEventListener?.("change", update);
}, []);
// Auto-play (disabled if reduced motion or paused or only 1 slide)
useEffect(() => {
if (slideCount <= 1 || isPaused || reducedMotion) return;
timerRef.current = setInterval(next, interval);
return () => clearInterval(timerRef.current);
}, [index, isPaused, interval, slideCount, reducedMotion]);
// Pause when tab hidden
useEffect(() => {
const onVis = () => setIsPaused(document.hidden || isPaused);
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, [isPaused]);
// Keyboard: arrows + Home/End
const onKeyDown = (e) => {
if (e.key === "ArrowRight") {
e.preventDefault();
next();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
prev();
} else if (e.key === "Home") {
e.preventDefault();
goTo(0);
} else if (e.key === "End") {
e.preventDefault();
goTo(slideCount - 1);
}
};
// Touch swipe
const onTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX;
touchDeltaX.current = 0;
setIsPaused(true);
};
const onTouchMove = (e) => {
if (touchStartX.current == null) return;
touchDeltaX.current = e.touches[0].clientX - touchStartX.current;
};
const onTouchEnd = () => {
if (touchStartX.current == null) return;
const threshold = 40; // px
if (touchDeltaX.current <= -threshold) next();
else if (touchDeltaX.current >= threshold) prev();
touchStartX.current = null;
touchDeltaX.current = 0;
setIsPaused(false);
};
if (slideCount === 0) {
return (
<section
aria-label={ariaLabel}
className="w-full aspect-[16/9] grid place-items-center rounded-xl bg-gray-200 text-gray-600"
>
No images
</section>
);
}
const heightClass = `h-[${heightPx}px]`; // Tailwind wont parse dynamic value; well inline style below
const atStart = !loop && index === 0;
const atEnd = !loop && index === slideCount - 1;
return (
<section
role="region"
aria-roledescription="carousel"
aria-label={ariaLabel}
className="relative w-full select-none"
style={{ height: `${heightPx}px` }}
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onFocus={() => setIsPaused(true)}
onBlur={() => setIsPaused(false)}
onKeyDown={onKeyDown}
tabIndex={0} // focusable for keyboard control
>
{/* Viewport */}
<div
className="overflow-hidden rounded-xl bg-black/5 w-full h-full"
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
{/* Track */}
<div
className={`flex ${reducedMotion ? "" : "transition-transform duration-500 ease-out"}`}
style={{ transform: `translateX(-${index * 100}%)`, height: "100%" }}
>
{images.map((img, i) => (
<figure key={i} className="shrink-0 w-full h-full m-0">
<img
src={img.src}
alt={img.alt ?? `Slide ${i + 1}`}
className="w-full h-full object-cover"
draggable={false}
loading={i === 0 ? "eager" : "lazy"}
decoding="async"
sizes={imgSizes}
/>
{img.caption && (
<figcaption className="text-center text-sm text-gray-600 dark:text-gray-300 mt-2">
{img.caption}
</figcaption>
)}
</figure>
))}
</div>
</div>
{/* Left / Right controls */}
<button
type="button"
aria-label="Previous slide"
onClick={prev}
disabled={atStart}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-white/80 dark:bg-neutral-900/80 border border-gray-300 dark:border-neutral-700 shadow px-3 py-2 hover:bg-white dark:hover:bg-neutral-900 disabled:opacity-50"
>
</button>
<button
type="button"
aria-label="Next slide"
onClick={next}
disabled={atEnd}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-white/80 dark:bg-neutral-900/80 border border-gray-300 dark:border-neutral-700 shadow px-3 py-2 hover:bg-white dark:hover:bg-neutral-900 disabled:opacity-50"
>
</button>
{/* Dots */}
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-2">
{images.map((_, i) => {
const active = i === index;
return (
<button
key={i}
type="button"
onClick={() => goTo(i)}
aria-label={`Go to slide ${i + 1}`}
aria-current={active ? "true" : undefined}
className={`h-2.5 rounded-full transition-all ${
active ? "w-6 bg-blue-500" : "w-2.5 bg-gray-300 dark:bg-neutral-700"
}`}
/>
);
})}
</div>
{/* Live region for screen readers */}
<p className="sr-only" aria-live="polite">
Slide {index + 1} of {slideCount}
{images[index]?.caption ? `${images[index].caption}` : ""}
</p>
</section>
);
}

257
src/components/NavBar.jsx Normal file
View File

@@ -0,0 +1,257 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import menuItems from "../data/menu.json";
import Button from "./Button";
import { Menu, MoonIcon, SunIcon, X } from "lucide-react";
export default function NavBar() {
// THEME
const [theme, setTheme] = useState(() => {
if (typeof window === "undefined") return "light";
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
});
useEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (theme === "dark") root.classList.add("dark");
else root.classList.remove("dark");
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
// MOBILE MENU
const [mobileOpen, setMobileOpen] = useState(false);
const toggleBtnRef = useRef(null);
const drawerRef = useRef(null);
const openMobile = () => setMobileOpen(true);
const closeMobile = () => setMobileOpen(false);
const toggleMobile = () => setMobileOpen((o) => !o);
// Prevent body scroll when drawer is open
useEffect(() => {
if (typeof document === "undefined") return;
document.body.style.overflow = mobileOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [mobileOpen]);
// Focus management & Esc to close for dialog
useEffect(() => {
if (!mobileOpen) {
// return focus to toggle button
toggleBtnRef.current?.focus();
return;
}
// move focus into the drawer
drawerRef.current?.focus();
const onKeyDown = (e) => {
if (e.key === "Escape") {
e.preventDefault();
closeMobile();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [mobileOpen]);
const menuData = menuItems.data;
return (
<>
{/* Skip link for keyboard users */}
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:bg-white focus:text-black focus:px-3 focus:py-2 rounded"
>
Skip to content
</a>
<header
className="fixed inset-x-0 top-0 z-50 bg-white dark:bg-neutral-900/95 backdrop-blur border-b border-gray-300 dark:border-neutral-700"
aria-label="Site header"
>
<div className="mx-auto w-full max-w-7xl px-5 lg:px-10">
<nav
className="flex items-center justify-between py-4 lg:py-2"
aria-label="Primary"
>
{/* Brand */}
<div>
<Link
to="/"
className="font-bold text-xl text-gray-900 dark:text-gray-100"
onClick={closeMobile}
>
Priscy<span className="text-blue-500">Designs</span>
</Link>
</div>
{/* Desktop menu */}
<ul
className="hidden lg:flex lg:flex-row gap-2 text-md font-medium text-gray-600 dark:text-gray-300"
role="list"
>
{menuData.map((item) => (
<li key={item.id} className="list-none">
<Link
to={item.link}
className="flex gap-2 items-center px-4 py-2 rounded-xl hover:bg-gray-200 dark:hover:bg-neutral-800"
>
<img
src={item.image}
alt="" /* decorative icon */
aria-hidden="true"
className="w-5 h-5"
/>
{item.title}
</Link>
</li>
))}
</ul>
{/* Desktop actions */}
<div className="hidden lg:flex items-center gap-8">
<button
type="button"
onClick={toggleTheme}
className="border border-gray-300 dark:border-neutral-700 rounded-lg py-1 px-2 text-sm text-gray-700 dark:text-gray-200 bg-white dark:bg-neutral-900 hover:bg-gray-100 dark:hover:bg-neutral-800"
aria-label={`Switch to ${
theme === "dark" ? "light" : "dark"
} mode`}
>
{theme === "dark" ? (
<SunIcon aria-hidden="true" />
) : (
<MoonIcon aria-hidden="true" />
)}
</button>
<Button
title="Let's Talk"
bgColor="bg-black dark:bg-blue-600"
hoverBgColor="hover:bg-blue-500 dark:hover:bg-blue-500"
/>
</div>
{/* Mobile toggle */}
<button
ref={toggleBtnRef}
type="button"
onClick={toggleMobile}
className="lg:hidden border border-gray-300 dark:border-neutral-700 rounded-lg py-1 px-3 text-sm text-gray-700 dark:text-gray-200 bg-white dark:bg-neutral-900"
aria-controls="mobile-drawer"
aria-expanded={mobileOpen}
aria-label={mobileOpen ? "Close menu" : "Open menu"}
>
{mobileOpen ? (
<X strokeWidth={0.75} />
) : (
<Menu strokeWidth={1.25} />
)}
</button>
</nav>
</div>
</header>
{/* Backdrop (presentational) */}
<button
type="button"
onClick={closeMobile}
aria-hidden="true"
tabIndex={-1}
className={`fixed inset-0 z-40 bg-black/40 transition-opacity lg:hidden ${
mobileOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
/>
{/* Mobile Drawer */}
<aside
id="mobile-drawer"
ref={drawerRef}
tabIndex={-1}
className={`fixed inset-y-0 left-0 z-50 w-72 max-w-[85%] lg:hidden
bg-white dark:bg-neutral-900 border-r border-gray-300 dark:border-neutral-800
transition-transform duration-300 ease-out
${mobileOpen ? "translate-x-0" : "-translate-x-full"}`}
role="dialog"
aria-modal="true"
aria-labelledby="mobile-title"
>
{/* Top: Brand */}
<div className="px-5 pt-4 pb-3 border-b border-gray-200 dark:border-neutral-800">
<Link
to="/"
className="font-bold text-2xl text-gray-900 dark:text-gray-100"
id="mobile-title"
onClick={closeMobile}
>
Priscy<span className="text-blue-500">Designs</span>
</Link>
</div>
{/* Middle: Menu items */}
<nav className="px-3 py-4" aria-label="Mobile menu">
<ul
className="flex flex-col gap-2 text-lg font-medium text-gray-700 dark:text-gray-300"
role="list"
>
{menuData.map((item) => (
<li key={item.id} className="list-none">
<Link
to={item.link}
onClick={closeMobile}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800"
>
<img
src={item.image}
alt=""
aria-hidden="true"
className="w-5 h-5"
/>
{item.title}
</Link>
</li>
))}
</ul>
</nav>
{/* Bottom: Actions */}
<div className="mt-auto absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-neutral-800 p-4 flex flex-col justify-between gap-3">
<button
type="button"
onClick={toggleTheme}
className="flex items-center gap-2 border border-gray-300 dark:border-neutral-700 rounded-lg py-4 px-3 text-sm text-gray-700 dark:text-gray-200 bg-white dark:bg-neutral-900 hover:bg-gray-100 dark:hover:bg-neutral-800"
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
>
{theme === "dark" ? (
<SunIcon size={18} aria-hidden="true" />
) : (
<MoonIcon size={18} aria-hidden="true" />
)}
<span>
{theme === "dark"
? "Switch to Light Mode"
: "Switch to Dark Mode"}
</span>
</button>
<Button
title="Let's Talk"
bgColor="bg-black dark:bg-blue-600"
hoverBgColor="hover:bg-blue-500 dark:hover:bg-blue-500"
/>
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,149 @@
import React, { useEffect, useMemo } from "react";
import { Link, useSearchParams } from "react-router-dom";
import projectsData from "../data/projects.json";
/**
* Props:
* - pagination (boolean) => show Prev/Next controls and read/write ?page= (default: true)
* - perPage (number) => items per page (default: 4)
* - prevText (string) => label for Prev (default: "Prev")
* - nextText (string) => label for Next (default: "Next")
*/
export default function ProjectCards({
pagination = true,
perPage = 4,
prevText = "Prev",
nextText = "Next",
}) {
const [params, setParams] = useSearchParams();
// Always call hooks; ignore when pagination=false
const pageFromUrl = Number(params.get("page")) || 1;
const totalPages = Math.max(1, Math.ceil(projectsData.length / (perPage || 1)));
const page = pagination ? Math.min(Math.max(1, pageFromUrl), totalPages) : 1;
// Keep URL valid if someone types ?page=abc or too big
useEffect(() => {
if (!pagination) return;
if (page !== pageFromUrl) {
setParams({ page: String(page) }, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination, page, pageFromUrl]);
// Slice for this page (or first chunk if pagination=false)
const projects = useMemo(() => {
const start = (page - 1) * perPage;
return projectsData.slice(start, start + perPage);
}, [page, perPage]);
// Scroll to top when changing page (only when paginating)
useEffect(() => {
if (!pagination) return;
window.scrollTo({ top: 0, behavior: "smooth" });
}, [pagination, page]);
const goto = (p) => setParams({ page: String(p) });
return (
<section className="space-y-6" aria-labelledby="projects-heading">
<h2 id="projects-heading" className="sr-only">
Projects
</h2>
{/* Grid list */}
<ul className="grid lg:grid-cols-2 gap-3" role="list">
{projects.map((p, i) => {
const slug = p.slug ?? p.title.toLowerCase().replace(/\s+/g, "-");
const key = p.id ?? slug ?? `${p.title}-${i}`;
return (
<li key={key} className="list-none">
<article
className="bg-[var(--card)] border border-[var(--border)] text-[var(--text)] rounded-lg"
aria-labelledby={`proj-title-${key}`}
>
<Link
to={`/portfolio/${slug}`}
className="block bg-[var(--card)] p-3 rounded-xl h-64 focus:outline-none focus-visible:ring focus-visible:ring-blue-500"
aria-label={`Open project: ${p.title}`}
>
<figure className="h-full w-full m-0">
<img
src={p.coverImage}
alt={p.title}
className="h-full w-full rounded-lg object-cover"
loading="lazy"
decoding="async"
/>
{/* figcaption is optional; keep SR-only to avoid visual noise */}
<figcaption className="sr-only">{p.title}</figcaption>
</figure>
</Link>
<div className="px-3 pt-0 flex flex-col gap-2 lg:flex-row lg:justify-between lg:items-center">
<h3 id={`proj-title-${key}`} className="text-xl font-semibold">
<Link
to={`/portfolio/${slug}`}
className="hover:underline focus:outline-none focus-visible:ring focus-visible:ring-blue-500 rounded"
>
{p.title}
</Link>
</h3>
<Link
to={`/portfolio/${slug}`}
className="text-blue-500 underline font-semibold"
aria-label={`See project details for ${p.title}`}
>
See Project
</Link>
</div>
{p.category && (
<p className="px-5 pb-3 text-sm text-gray-600 dark:text-gray-300">
<span className="sr-only">Category: </span>
{p.category}
</p>
)}
</article>
</li>
);
})}
</ul>
{/* Prev / Next (optional) */}
{pagination && (
<nav className="flex items-center justify-center gap-2" aria-label="Pagination">
<button
type="button"
onClick={() => goto(page - 1)}
disabled={page === 1}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-800 disabled:opacity-50"
aria-label="Go to previous page"
>
{prevText}
</button>
{/* Announce page changes politely for AT */}
<span
className="text-sm text-gray-600 dark:text-gray-300 px-2"
aria-live="polite"
>
Page {page} of {totalPages}
</span>
<button
type="button"
onClick={() => goto(page + 1)}
disabled={page === totalPages}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-800 disabled:opacity-50"
aria-label="Go to next page"
>
{nextText}
</button>
</nav>
)}
</section>
);
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import WorkTogetherSlider from "./WorkTogetherSlider";
import ProjectCards from "./ProjectCards";
export default function ProjectDetails() {
return (
<div
className="col-span-2 bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)] rounded-xl p-7 flex flex-col gap-10"
>
<div className="">
<div className="flex flex-col gap-5">
<h2 className="text-2xl lg:text-4xl font-semibold">
Check Out My Latest <span className="text-blue-600">Projects</span>
</h2>
<p className=" lg:text-xl">
I'm here to help if you're searching for a product designer to bring
your idea to life or a design partner to help take your business to
the next level.
</p>
</div>
</div>
<ProjectCards />
<WorkTogetherSlider />
</div>
);
}

View File

@@ -0,0 +1,46 @@
import React from "react";
import ServiceOffer from "./ServiceOffer";
import bannerImg from "/assets/blog-img.jpg";
import Brands from "./Brands";
import Testimonials from "./Testimonials";
import Certifications from "./Certifications";
import Faq from "./Faq";
import WorkTogetherSlider from "./WorkTogetherSlider";
export default function ServiceDetails() {
return (
<div className="col-span-2 bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)] rounded-xl p-7 flex flex-col gap-10">
<div className="">
<div className="flex items-start flex-col-reverse gap-5 lg:flex-row justify-between lg:items-center">
<h2 className="text-4xl font-semibold">
Services I <span className="text-blue-600">Offer</span>
</h2>
<p className="text-lg text-blue-600 font-semibold bg-gray-200 rounded-lg py-2 px-3">
Available For Hire
</p>
</div>
</div>
<p className="text-lg lg:w-[50%] lg:text-2xl">
Transforming Ideas into Innovative Reality, Elevate Your Vision with Our
Expert <strong>Product Design and Development</strong> Services!
</p>
<div className="flex flex-col gap-10">
<ServiceOffer />
<img
src={bannerImg}
alt="banner image"
className="rounded-lg object-cover h-92 w-full"
/>
<Brands />
<Testimonials />
<Certifications />
<Faq />
<WorkTogetherSlider />
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import React from "react";
import { Link } from "react-router-dom";
import rightArrow from "/assets/arrow-right.svg";
import workData from "../data/work.json";
export default function ServiceOffer() {
const workLimit = workData.slice(0, 4);
return (
<section
className="bg-[var(--card)] border border-[var(--border)] text-[var(--text)] rounded-xl p-3 flex flex-col gap-6 lg:col-span-2"
aria-labelledby="services-offer-heading"
>
<div className="flex flex-col gap-2 lg:justify-between">
<h2 id="services-offer-heading" className="text-2xl font-semibold">
Services I Offer
</h2>
<nav aria-label="All services">
<Link
to="/services"
className="inline-flex items-center gap-2 text-blue-500 font-semibold hover:underline focus:outline-none focus-visible:ring focus-visible:ring-blue-500 rounded"
aria-label="See all services"
>
<span>See All Services</span>
<img
src={rightArrow}
alt="" /* decorative */
aria-hidden="true"
className="w-4 h-4"
loading="lazy"
decoding="async"
/>
</Link>
<hr className="text-gray-400" />
</nav>
</div>
<ul className="grid grid-cols-2 lg:grid-cols-4 gap-5" role="list">
{workLimit.map((work) => (
<li key={work.id} className="list-none">
<article
className="bg-[var(--card)] border border-[var(--border)] text-[var(--text)] rounded-xl p-5 flex flex-col gap-3"
aria-labelledby={`service-title-${work.id}`}
>
<figure className="bg-[var(--card)] border border-[var(--border)] text-[var(--text)] rounded-lg px-5 py-8 flex items-center justify-center">
<img
src={work.image}
alt="" /* decorative brand/service icon */
aria-hidden="true"
className="w-14 h-14 object-contain"
loading="lazy"
decoding="async"
/>
<figcaption className="sr-only">{work.company}</figcaption>
</figure>
<h3
id={`service-title-${work.id}`}
className="text-center text-lg font-semibold"
>
{work.company}
</h3>
</article>
</li>
))}
</ul>
</section>
);
}

View File

@@ -0,0 +1,282 @@
import React, { useEffect, useRef, useState, useMemo } from "react";
import data from "../data/testimonials.json";
/**
* Testimonials slider (accessible)
* - Auto-advances every few seconds (pauses on hover/focus and reduced motion)
* - Keyboard: Left/Right, Home/End
* - Touch swipe on mobile
* - Next/Prev buttons are pinned top-right on lg+
*/
export default function Testimonials({
interval = 5000,
loop = true,
ariaLabel = "Client testimonials",
}) {
const testimonials = useMemo(() => data ?? [], []);
const count = testimonials.length;
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const [reducedMotion, setReducedMotion] = useState(false);
const timerRef = useRef(null);
const trackRef = useRef(null);
const touchStartX = useRef(null);
const touchDeltaX = useRef(0);
const clamp = (i) => {
if (count === 0) return 0;
return loop ? (i + count) % count : Math.max(0, Math.min(count - 1, i));
};
const goTo = (i) => setIndex(clamp(i));
const next = () => goTo(index + 1);
const prev = () => goTo(index - 1);
// Reduced motion
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
const update = () => setReducedMotion(!!mq?.matches);
update();
mq?.addEventListener?.("change", update);
return () => mq?.removeEventListener?.("change", update);
}, []);
// Autoplay
useEffect(() => {
if (count <= 1 || paused || reducedMotion) return;
timerRef.current = setInterval(next, interval);
return () => clearInterval(timerRef.current);
}, [index, paused, interval, count, reducedMotion]);
// Pause when tab hidden
useEffect(() => {
const onVis = () => setPaused((p) => document.hidden || p);
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
// Keyboard
const onKeyDown = (e) => {
if (e.key === "ArrowRight") {
e.preventDefault();
next();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
prev();
} else if (e.key === "Home") {
e.preventDefault();
goTo(0);
} else if (e.key === "End") {
e.preventDefault();
goTo(count - 1);
}
};
// Touch
const onTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX;
touchDeltaX.current = 0;
setPaused(true);
};
const onTouchMove = (e) => {
if (touchStartX.current == null) return;
touchDeltaX.current = e.touches[0].clientX - touchStartX.current;
};
const onTouchEnd = () => {
if (touchStartX.current == null) return;
const threshold = 40;
if (touchDeltaX.current <= -threshold) next();
else if (touchDeltaX.current >= threshold) prev();
touchStartX.current = null;
touchDeltaX.current = 0;
setPaused(false);
};
if (count === 0) {
return (
<section
aria-label={ariaLabel}
className="rounded-2xl p-6 bg-[var(--card)] border border-[var(--border)] text-[var(--text)]"
>
<h2 className="text-3xl font-semibold mb-3">Trusted By Clients</h2>
<p>No testimonials yet.</p>
</section>
);
}
const atStart = !loop && index === 0;
const atEnd = !loop && index === count - 1;
return (
<section
aria-labelledby="testimonials-heading"
className="relative rounded-2xl p-6 bg-[var(--card)] border border-[var(--border)] text-[var(--text)]"
>
<div className="flex items-start justify-between gap-3">
<div>
<h2 id="testimonials-heading" className="text-3xl font-semibold">
Trusted By 1200+ Clients
</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
Real words from the teams we&apos;ve partnered with.
</p>
</div>
{/* Top-right controls on large screens */}
<div className="hidden lg:flex items-center gap-2">
<button
type="button"
onClick={prev}
disabled={atStart}
className="px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] hover:bg-[var(--hover)] disabled:opacity-50"
aria-label="Previous testimonial"
>
Prev
</button>
<button
type="button"
onClick={next}
disabled={atEnd}
className="px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] hover:bg-[var(--hover)] disabled:opacity-50"
aria-label="Next testimonial"
>
Next
</button>
</div>
</div>
{/* Carousel */}
<div
role="region"
aria-roledescription="carousel"
aria-label={ariaLabel}
className="mt-5 relative overflow-hidden rounded-xl"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
onKeyDown={onKeyDown}
tabIndex={0}
>
{/* Track */}
<ul
ref={trackRef}
className={`flex ${
reducedMotion ? "" : "transition-transform duration-500 ease-out"
}`}
style={{ transform: `translateX(-${index * 100}%)` }}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
role="list"
>
{testimonials.map((t, i) => (
<li key={t.id} className="list-none shrink-0 w-full">
<article
className="grid gap-5 md:grid-cols-[auto,1fr] items-start bg-[var(--card)]"
aria-label={`Testimonial from ${t.name}`}
>
{/* Avatar */}
<figure className="w-16 h-16 md:w-20 md:h-20 rounded-full overflow-hidden border border-[var(--border)] mx-auto md:mx-0">
<img
src={t.avatar}
alt={`Avatar of ${t.name}`}
className="w-full h-full object-cover"
loading={i === 0 ? "eager" : "lazy"}
decoding="async"
draggable={false}
/>
<figcaption className="sr-only">{t.name}</figcaption>
</figure>
{/* Quote card */}
<div className="rounded-2xl border border-[var(--border)] p-5 bg-[var(--card)]">
{/* Stars */}
<div
className="flex items-center gap-1 mb-2"
aria-label={`Rated ${t.rating} out of 5`}
>
{[...Array(5)].map((_, s) => (
<svg
key={s}
viewBox="0 0 20 20"
className={`w-4 h-4 ${
s < t.rating
? "text-yellow-400"
: "text-gray-300 dark:text-neutral-700"
}`}
aria-hidden="true"
>
<path d="M10 15.27l5.18 3.05-1.64-5.81 4.46-3.73-5.86-.5L10 2 7.86 8.28l-5.86.5 4.46 3.73-1.64 5.81L10 15.27z" />
</svg>
))}
</div>
<blockquote className="text-lg leading-relaxed">
{t.quote}
</blockquote>
<footer className="mt-3">
<p className="font-semibold">{t.name}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
{t.role}
</p>
</footer>
</div>
</article>
</li>
))}
</ul>
{/* Mobile controls (bottom overlay) */}
<div className="flex lg:hidden justify-center gap-3 mt-4">
<button
type="button"
onClick={prev}
disabled={atStart}
className="px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] hover:bg-[var(--hover)] disabled:opacity-50"
aria-label="Previous testimonial"
>
Prev
</button>
<button
type="button"
onClick={next}
disabled={atEnd}
className="px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] hover:bg-[var(--hover)] disabled:opacity-50"
aria-label="Next testimonial"
>
Next
</button>
</div>
{/* Dots */}
<div className="flex items-center justify-center gap-2 mt-4">
{testimonials.map((_, i) => {
const active = i === index;
return (
<button
key={i}
type="button"
onClick={() => goTo(i)}
aria-label={`Go to testimonial ${i + 1}`}
aria-current={active ? "true" : undefined}
className={`h-2.5 rounded-full transition-all ${
active
? "w-6 bg-blue-500"
: "w-2.5 bg-gray-300 dark:bg-neutral-700"
}`}
/>
);
})}
</div>
{/* Live region */}
<p className="sr-only" aria-live="polite">
Testimonial {index + 1} of {count}. {testimonials[index]?.name}.
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,83 @@
import React from "react";
import workData from "../data/work.json";
export default function WorkExperience() {
return (
<div className="">
{" "}
{/* 50% of parent */}
<div
className="bg-[var(--card)]
border border-[var(--border)]
text-[var(--text)] rounded-xl shadow px-5 py-3"
>
<h1 className="text-2xl font-semibold py-2">Work Experience</h1>
{/* Auto-scrolling viewport */}
<div className="group relative h-60 overflow-hidden">
{" "}
{/* adjust h-80 as you like */}
<div className="animate-scroll-up group-hover:[animation-play-state:paused]">
{/* CONTENT BLOCK 1 */}
<div className="space-y-6">
{workData.map((work) => (
<div key={`a-${work.id}`} className="flex gap-4 items-center">
<div className="w-[35%] shrink-0">
<p>{work.year}</p>
</div>
<div className=" w-12 h-10 p-1 rounded-xl shrink-0">
<img
src={work.image}
alt={`${work.company} icon`}
className=" w-full object-contain"
loading="lazy"
/>
</div>
<div className="w-[50%]">
<h3 className="font-bold">{work.company}</h3>
<p>{work.position}</p>
</div>
</div>
))}
</div>
{/* CONTENT BLOCK 2 (duplicate for seamless loop) */}
<div className="space-y-6" aria-hidden="true">
{workData.map((work) => (
<div key={`b-${work.id}`} className="flex gap-4 items-center">
<div className="w-[35%] shrink-0">
<p>{work.year}</p>
</div>
<div className=" w-12 h-10 p-1 rounded-xl shrink-0">
<img
src={work.image}
alt=""
className="w-full object-contain"
loading="lazy"
/>
</div>
<div className="w-[50%]">
<h3 className="font-bold">{work.company}</h3>
<p>{work.position}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Local styles for the marquee effect */}
<style>{`
@keyframes scroll-up {
0% { transform: translateY(0); }
100% { transform: translateY(-50%); } /* move past the first block */
}
.animate-scroll-up {
display: grid; /* ensures the two blocks stack without extra gaps */
gap: 1.5rem; /* matches space-y-6 (1.5rem) visually; optional */
animation: scroll-up 20s linear infinite; /* adjust duration for speed */
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import React from "react";
import rightArrow from "/assets/arrow-right.svg";
import { Link } from "react-router-dom";
export default function WorkTogetherCard() {
const ticker =
"Available For Hire — Crafting Digital Experiences — Available For Hire — Crafting Digital Experiences";
return (
<section
className="bg-[var(--card)] border border-[var(--border)] text-[var(--text)] rounded-xl p-7 flex flex-col gap-6 justify-between"
aria-labelledby="work-together-heading"
>
{/* Marquee (hidden from AT; provide SR-only static alt below) */}
<div
className="group relative overflow-hidden rounded-lg bg-gray-200 py-2"
aria-hidden="true"
>
<div className="marquee flex w-max whitespace-nowrap group-hover:[animation-play-state:paused] group-focus-within:[animation-play-state:paused]">
<p className="px-4 py-2 text-xl text-gray-800">{ticker}</p>
{/* duplicate for seamless loop */}
<p
className="px-4 py-2 text-xl font-semibold text-gray-800"
aria-hidden="true"
>
{ticker}
</p>
</div>
</div>
{/* SR-only static equivalent of the marquee text */}
<p className="sr-only">
Available for hire. Crafting digital experiences.
</p>
<h2
id="work-together-heading"
className="text-4xl font-semibold leading-11 lg:leading-13"
>
<span>Let&apos;s</span>
<span aria-hidden="true">👋</span>
<br />
Work Together
</h2>
<div className="flex flex-col w-[40%] lg:w-[40%]">
<Link
to="/services"
className="inline-flex items-center gap-2 text-blue-500 font-semibold hover:underline focus:outline-none focus-visible:ring focus-visible:ring-blue-500 rounded"
aria-label="Contact me about services"
>
<span>Let&apos;s Talk</span>
<img
src={rightArrow}
alt=""
aria-hidden="true"
className="w-4 h-4"
loading="lazy"
decoding="async"
/>
</Link>
<hr className="text-gray-400" aria-hidden="true" />
</div>
{/* Local styles for the marquee + reduced-motion support */}
<style>{`
@keyframes marquee-x {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); } /* moves past the first copy */
}
.marquee {
animation: marquee-x 18s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.marquee {
animation: none !important;
transform: none !important;
}
}
`}</style>
</section>
);
}

View File

@@ -0,0 +1,59 @@
import React from "react";
import { Link } from "react-router-dom";
export default function WorkTogetherSlider() {
const ticker = "Let's 👋 Work Together · Let's 👋 Work Together ·";
return (
<section
className="max-w-full"
aria-labelledby="work-together-slider-heading"
>
<h2 id="work-together-slider-heading" className="sr-only">
Work Together Call-to-Action
</h2>
{/* Full-width clickable viewport */}
<Link
to="/contact"
className="group block"
aria-label="Contact me — Let's work together"
>
<div
className="relative h-16 overflow-hidden rounded-lg bg-[var(--card)]
border border-[var(--border)] text-[var(--text)]
focus:outline-none focus-visible:ring focus-visible:ring-blue-500"
tabIndex={-1}
aria-hidden="true" /* hide animated content; static label below */
>
{/* Absolutely positioned track so it can't expand layout width */}
<div className="marquee absolute inset-0 flex items-center will-change-transform group-hover:[animation-play-state:paused] group-focus-within:[animation-play-state:paused]">
<p className="px-4 text-3xl shrink-0">{ticker}</p>
{/* duplicate for seamless loop */}
<p className="px-4 text-3xl shrink-0" aria-hidden="true">
{ticker}
</p>
</div>
</div>
{/* Static equivalent for screen readers */}
<span className="sr-only">Lets work together contact me</span>
</Link>
{/* Local styles for the marquee */}
<style>{`
@keyframes marquee-x {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); } /* slides past the first copy */
}
.marquee {
animation: marquee-x 18s linear infinite;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.marquee { animation: none !important; transform: none !important; }
}
`}</style>
</section>
);
}

137
src/data/blog.json Normal file
View File

@@ -0,0 +1,137 @@
[
{
"id": 1,
"slug": "designing-for-speed",
"category": "Performance",
"title": "Designing for Speed: Faster UI, Happier Users",
"excerpt": "Practical ways to make interfaces feel instant—perceived performance, skeletons, and optimistic updates.",
"body": "We explore perceived performance techniques, including skeleton screens, optimistic actions, and prefetching routes. We also cover image strategy, font loading, and micro-interactions that mask latency.",
"coverImage": "https://images.unsplash.com/photo-1498050108023-c5249f4df085?q=80&w=1600&auto=format&fit=crop",
"images": [
"https://images.unsplash.com/photo-1519389950473-47ba0277781c?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1498050108023-c5249f4df085?q=80&w=1600&auto=format&fit=crop"
],
"author": "Priscy",
"date": "2025-03-06",
"url": "https://example.com/blog/designing-for-speed"
},
{
"id": 2,
"slug": "color-systems-in-2025",
"category": "Design",
"title": "Color Systems in 2025: Tokens, Modes, and Contrast",
"excerpt": "Dark mode, high contrast themes, and how to structure tokens so brand changes are painless.",
"body": "We map semantic tokens to modes, discuss WCAG contrast, and show how to keep brand palettes flexible without breaking components.",
"coverImage": "https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?q=80&w=1600&auto=format&fit=crop",
"images": [
"https://images.unsplash.com/photo-1512496015851-a90fb38ba796?q=80&w=1600&auto=format&fit=crop"
],
"author": "Priscy",
"date": "2025-02-14",
"url": "https://example.com/blog/color-systems-in-2025"
},
{
"id": 3,
"slug": "portfolio-case-study-template",
"category": "Process",
"title": "A Reusable Portfolio Case Study Template",
"excerpt": "Tell a tight story: problem → approach → impact. A simple outline you can reuse across projects.",
"body": "This template keeps you focused on outcomes. We include example sections, asset checklists, and metrics to highlight.",
"coverImage": "https://images.unsplash.com/photo-1493666438817-866a91353ca9?q=80&w=1600&auto=format&fit=crop",
"images": [],
"author": "Priscy",
"date": "2025-01-22",
"url": "https://example.com/blog/portfolio-case-study-template"
},
{
"id": 4,
"slug": "microinteractions-that-delight",
"category": "UI/UX",
"title": "Micro-interactions That Delight (Without Being Noisy)",
"excerpt": "Small, purposeful animations that guide attention and communicate state.",
"body": "We cover duration, easing, and how to use motion as feedback rather than decoration. Includes examples and do/don'ts.",
"coverImage": "https://images.unsplash.com/photo-1550565118-3a14e8d038d9?q=80&w=1600&auto=format&fit=crop",
"images": [],
"author": "Priscy",
"date": "2024-12-11",
"url": "https://example.com/blog/microinteractions-that-delight"
},
{
"id": 5,
"slug": "building-accessible-modals",
"category": "Accessibility",
"title": "Building Accessible Modals in React",
"excerpt": "Focus traps, aria labels, scroll locking, and escape hatches.",
"body": "A checklist and code snippets for modals that work for everyone—including keyboard and screen reader users.",
"coverImage": "https://images.unsplash.com/photo-1518779578993-ec3579fee39f?q=80&w=1600&auto=format&fit=crop",
"images": [],
"author": "Priscy",
"date": "2024-11-05",
"url": "https://example.com/blog/building-accessible-modals"
},
{
"id": 6,
"slug": "image-loading-strategies",
"category": "Performance",
"title": "Smart Image Loading for Portfolio Sites",
"excerpt": "When to use `object-fit`, responsive sources, and lazy loading to keep pages snappy.",
"body": "We compare techniques for art direction and bandwidth savings: `srcset`, `sizes`, `loading=lazy`, and preloading hero assets.",
"coverImage": "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?q=80&w=1600&auto=format&fit=crop",
"images": [],
"author": "Priscy",
"date": "2024-10-10",
"url": "https://example.com/blog/image-loading-strategies"
},
{
"id": 7,
"slug": "design-handoff-checklist",
"category": "Process",
"title": "Design → Dev Handoff Checklist",
"excerpt": "Everything devs need: tokens, states, empty/error screens, and motion specs.",
"body": "Use this checklist before handoff to reduce rework and mismatches in production.",
"coverImage": "https://images.unsplash.com/photo-1519389950473-47ba0277781c?q=80&w=1600&auto=format&fit=crop",
"images": [],
"author": "Priscy",
"date": "2024-08-28",
"url": "https://example.com/blog/design-handoff-checklist"
},
{
"id": 8,
"slug": "grid-layouts-that-scale",
"category": "CSS",
"title": "Grid Layouts That Scale Across Breakpoints",
"excerpt": "Fluid columns, minmax, clamp, and intrinsic sizing patterns.",
"body": "We show common responsive patterns that keep content readable at any width.",
"coverImage": "https://images.unsplash.com/photo-1491884662610-dfcd28f30cf5?q=80&w=1600&auto=format&fit=crop",
"images": [],
"author": "Priscy",
"date": "2024-07-17",
"url": "https://example.com/blog/grid-layouts-that-scale"
},
{
"id": 9,
"slug": "navbars-that-dont-annoy",
"category": "UI/UX",
"title": "Sticky Navbars That Dont Annoy Users",
"excerpt": "When to use sticky vs fixed, and how to keep them out of the way.",
"body": "Tradeoffs, safe areas, and scroll-aware patterns that balance access with focus.",
"coverImage": "https://images.unsplash.com/photo-1520975916090-3105956dac38?q=80&w=1600&auto=format&fit=crop",
"images": [],
"author": "Priscy",
"date": "2024-06-02",
"url": "https://example.com/blog/navbars-that-dont-annoy"
},
{
"id": 10,
"slug": "writing-case-studies-people-read",
"category": "Content",
"title": "Writing Case Studies People Actually Read",
"excerpt": "Short intros, visuals first, and clear outcomes—skip the fluff.",
"body": "How to structure, what to cut, and quick heuristics to keep readers engaged.",
"coverImage": "https://images.unsplash.com/photo-1493666438207-1c01b1c1b6b6?q=80&w=1600&auto=format&fit=crop",
"images": [],
"author": "Priscy",
"date": "2024-05-01",
"url": "https://example.com/blog/writing-case-studies-people-read"
}
]

82
src/data/brands.json Normal file
View File

@@ -0,0 +1,82 @@
[
{
"name": "Acme Studios",
"image": "https://placehold.co/320x160?text=Acme+Studios+Logo"
},
{
"name": "Northstar Labs",
"image": "https://placehold.co/320x160?text=Northstar+Labs+Logo"
},
{
"name": "Aurora Apparel",
"image": "https://placehold.co/320x160?text=Aurora+Apparel+Logo"
},
{
"name": "Pixel Forge",
"image": "https://placehold.co/320x160?text=Pixel+Forge+Logo"
},
{
"name": "Summit Health",
"image": "https://placehold.co/320x160?text=Summit+Health+Logo"
},
{
"name": "Blue Harbor",
"image": "https://placehold.co/320x160?text=Blue+Harbor+Logo"
},
{
"name": "Zenith Finance",
"image": "https://placehold.co/320x160?text=Zenith+Finance+Logo"
},
{
"name": "Ember Coffee",
"image": "https://placehold.co/320x160?text=Ember+Coffee+Logo"
},
{
"name": "Lighthouse Travel",
"image": "https://placehold.co/320x160?text=Lighthouse+Travel+Logo"
},
{
"name": "Nimbus Cloud",
"image": "https://placehold.co/320x160?text=Nimbus+Cloud+Logo"
},
{
"name": "Terra Foods",
"image": "https://placehold.co/320x160?text=Terra+Foods+Logo"
},
{
"name": "Silverline Media",
"image": "https://placehold.co/320x160?text=Silverline+Media+Logo"
},
{
"name": "Echo Fitness",
"image": "https://placehold.co/320x160?text=Echo+Fitness+Logo"
},
{
"name": "Horizon Motors",
"image": "https://placehold.co/320x160?text=Horizon+Motors+Logo"
},
{
"name": "Atlas Outdoor",
"image": "https://placehold.co/320x160?text=Atlas+Outdoor+Logo"
},
{
"name": "Velvet Cosmetics",
"image": "https://placehold.co/320x160?text=Velvet+Cosmetics+Logo"
},
{
"name": "Quantum Learning",
"image": "https://placehold.co/320x160?text=Quantum+Learning+Logo"
},
{
"name": "Drift Bikes",
"image": "https://placehold.co/320x160?text=Drift+Bikes+Logo"
},
{
"name": "Sunbeam Energy",
"image": "https://placehold.co/320x160?text=Sunbeam+Energy+Logo"
},
{
"name": "Meadow Petcare",
"image": "https://placehold.co/320x160?text=Meadow+Petcare+Logo"
}
]

View File

@@ -0,0 +1,34 @@
[
{
"id": 1,
"title": "Cousera UI/UX Design",
"year": "2022 - 2023",
"status": "Completed",
"link": "#",
"image": "/assets/facebook.svg"
},
{
"id": 2,
"title": "Adobe UI/UX Master Class",
"year": "2022 - 2023",
"status": "Completed",
"link": "#",
"image": "/assets/instagram.svg"
},
{
"id": 3,
"title": "Figma UI/UX Design",
"year": "2022 - 2023",
"status": "Completed",
"link": "#",
"image": "/assets/facebook.svg"
},
{
"id": 4,
"title": "DevOps UI/UX Master Class",
"year": "2022 - 2023",
"status": "Completed",
"link": "#",
"image": "/assets/instagram.svg"
}
]

27
src/data/faq.json Normal file
View File

@@ -0,0 +1,27 @@
[
{
"id": 1,
"question": "What does a product designer need to know?",
"answer": "I'm here to help if you're searching for a product designer to bring your idea to life or a design partner to help take your business to the next level."
},
{
"id": 2,
"question": "What does a product designer need to know?",
"answer": "I'm here to help if you're searching for a product designer to bring your idea to life or a design partner to help take your business to the next level."
},
{
"id": 3,
"question": "What does a product designer need to know?",
"answer": "I'm here to help if you're searching for a product designer to bring your idea to life or a design partner to help take your business to the next level."
},
{
"id": 4,
"question": "What does a product designer need to know?",
"answer": "I'm here to help if you're searching for a product designer to bring your idea to life or a design partner to help take your business to the next level."
},
{
"id": 5,
"question": "What does a product designer need to know?",
"answer": "I'm here to help if you're searching for a product designer to bring your idea to life or a design partner to help take your business to the next level."
}
]

40
src/data/menu.json Normal file
View File

@@ -0,0 +1,40 @@
{
"data": [
{
"id": 1,
"title": "Home",
"link": "/",
"image": "/assets/house.svg"
},
{
"id": 2,
"title": "About",
"link": "/about",
"image": "/assets/user.svg"
},
{
"id": 3,
"title": "Services",
"link": "/services",
"image": "/assets/layers.svg"
},
{
"id": 4,
"title": "Portfolio",
"link": "/portfolio",
"image": "/assets/briefcase.svg"
},
{
"id": 5,
"title": "Blog",
"link": "/blog",
"image": "/assets/pencil.svg"
},
{
"id": 6,
"title": "Contact",
"link": "/contact",
"image": "/assets/message.svg"
}
]
}

80
src/data/projects.json Normal file
View File

@@ -0,0 +1,80 @@
[
{
"id": 1,
"category": "Web Design",
"title": "Minimal Furniture Shop",
"body": "A clean e-commerce concept focusing on crisp typography and generous whitespace. Built with React and a headless cart pattern to keep interactions snappy.",
"images": [
"https://images.unsplash.com/photo-1493666438817-866a91353ca9?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1524758631624-e2822e304c36?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1493666438207-1c01b1c1b6b6?q=80&w=1600&auto=format&fit=crop"
],
"coverImage": "https://images.unsplash.com/photo-1493666438817-866a91353ca9?q=80&w=1200&auto=format&fit=crop",
"url": "https://example.com/projects/minimal-furniture"
},
{
"id": 2,
"category": "Branding",
"title": "Cold Brew Identity",
"body": "Logo, color system, and can label layout for a small-batch cold brew startup. The palette leans into deep blues and copper accents.",
"images": [
"https://images.unsplash.com/photo-1511920170033-f8396924c348?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1453614512568-c4024d13c247?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?q=80&w=1600&auto=format&fit=crop"
],
"coverImage": "https://images.unsplash.com/photo-1519389950473-47ba0277781c?q=80&w=1200&auto=format&fit=crop",
"url": "https://example.com/projects/cold-brew-identity"
},
{
"id": 3,
"category": "UI/UX",
"title": "Wellness Mobile App",
"body": "Meditation and habit-tracking flows with gentle micro-interactions. Focused on clarity and accessibility with large tap targets and calm motion.",
"images": [
"https://images.unsplash.com/photo-1550565118-3a14e8d038d9?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1512496015851-a90fb38ba796?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?q=80&w=1600&auto=format&fit=crop"
],
"coverImage": "https://images.unsplash.com/photo-1493666438817-866a91353ca9?q=80&w=1200&auto=format&fit=crop",
"url": "https://example.com/projects/wellness-app"
},
{
"id": 4,
"category": "Development",
"title": "SaaS Dashboard",
"body": "Admin dashboard with role-based access, dark mode, and lightweight charts. Emphasis on performance and a cohesive component system.",
"images": [
"https://images.unsplash.com/photo-1551281044-8d8e8aa0f0a8?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1519389950473-47ba0277781c?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1498050108023-c5249f4df085?q=80&w=1600&auto=format&fit=crop"
],
"coverImage": "https://images.unsplash.com/photo-1519389950473-47ba0277781c?q=80&w=1200&auto=format&fit=crop",
"url": "https://example.com/projects/saas-dashboard"
},
{
"id": 5,
"category": "Illustration",
"title": "City Nights Poster Series",
"body": "A trio of vector posters exploring neon-lit cityscapes. Strong geometry, halftone textures, and a vibrant, high-contrast palette.",
"images": [
"https://images.unsplash.com/photo-1508057198894-247b23fe5ade?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1491884662610-dfcd28f30cf5?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1469474968028-56623f02e42e?q=80&w=1600&auto=format&fit=crop"
],
"coverImage": "https://images.unsplash.com/photo-1508057198894-247b23fe5ade?q=80&w=1200&auto=format&fit=crop",
"url": "https://example.com/projects/city-nights"
},
{
"id": 6,
"category": "Photography",
"title": "Analog Portraits",
"body": "A study in natural light and grain shot on 35mm film. Subtle color grading to preserve the character of the stock.",
"images": [
"https://images.unsplash.com/photo-1524504388940-b1c1722653e1?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1520975916090-3105956dac38?q=80&w=1600&auto=format&fit=crop",
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=1600&auto=format&fit=crop"
],
"coverImage": "https://images.unsplash.com/photo-1524504388940-b1c1722653e1?q=80&w=1200&auto=format&fit=crop",
"url": "https://example.com/projects/analog-portraits"
}
]

View File

@@ -0,0 +1,50 @@
[
{
"id": 1,
"name": "Amara O.",
"role": "Head of Product, Nebula Pay",
"rating": 5,
"quote": "Priscy delivered beyond expectations. The UX flow cut our onboarding time by 37% and customers noticed immediately.",
"avatar": "https://i.pravatar.cc/120?img=1"
},
{
"id": 2,
"name": "David K.",
"role": "Founder, CraftCart",
"rating": 5,
"quote": "Clean code, crisp design, and excellent communication. Launch went smoothly and the site is blazing fast.",
"avatar": "https://i.pravatar.cc/120?img=2"
},
{
"id": 3,
"name": "Lina P.",
"role": "Marketing Lead, Oceanic",
"rating": 4,
"quote": "Our rebrand looks modern and consistent across channels. The design system has been a game changer for our team.",
"avatar": "https://i.pravatar.cc/120?img=3"
},
{
"id": 4,
"name": "Zach M.",
"role": "CTO, ByteForge",
"rating": 5,
"quote": "Delivered a robust dashboard with thoughtful accessibility. Dark mode is immaculate.",
"avatar": "https://i.pravatar.cc/120?img=4"
},
{
"id": 5,
"name": "Chidera N.",
"role": "Ops Manager, FreshHub",
"rating": 5,
"quote": "From kickoff to handoff, everything was clear and on schedule. Mobile conversion improved noticeably.",
"avatar": "https://i.pravatar.cc/120?img=5"
},
{
"id": 6,
"name": "Marco S.",
"role": "CEO, AtlasLabs",
"rating": 4,
"quote": "Reliable partner. The team understood our constraints and shipped a polished MVP on time.",
"avatar": "https://i.pravatar.cc/120?img=6"
}
]

37
src/data/work.json Normal file
View File

@@ -0,0 +1,37 @@
[
{
"id": 1,
"year": "2020 - Present",
"position": "Frontend Developer",
"company": "Jive",
"image": "/assets/facebook.svg"
},
{
"id": 2,
"year": "2018 - 2020",
"position": "UI/UX Designer",
"company": "Figma",
"image": "/assets/figma.svg"
},
{
"id": 3,
"year": "2016 - 2018",
"position": "Intern Developer",
"company": "Web Startup",
"image": "/assets/instagram.svg"
},
{
"id": 4,
"year": "2015 - 2016",
"position": "Junior Developer",
"company": "Code Factory",
"image": "/assets/linkedin.svg"
},
{
"id": 5,
"year": "2014 - 2015",
"position": "Trainee",
"company": "DevWorks",
"image": "/assets/facebook.svg"
}
]

88
src/index.css Normal file
View File

@@ -0,0 +1,88 @@
@import "tailwindcss";
:root {
--font-sans: "Bricolage Grotesque", -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans",
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
--grad-1: #7dd3fc; /* sky-300 */
--grad-2: #c4b5fd; /* violet-300 */
--grad-3: #fda4af; /* rose-300 */
--grad-4: #86efac; /* green-300 */
--bg: #ffffff;
--text: #0a0a0a;
--card: #ffffff;
--brandbg: #040c31;
--border: #e5e7eb;
--hover: #e5e7eb;
}
html.dark {
--bg: #0a0a0a;
--text: #f5f5f5;
--card: #0f0f10;
--border: #27272a;
--hover: #1f1f22;
}
html,
body,
#root {
height: 100%;
}
body {
font-family: var(--font-sans);
margin: 0;
background: var(--bg);
color: var(--text);
background: linear-gradient(
120deg,
var(--grad-1),
var(--grad-2),
var(--grad-3),
var(--grad-4)
);
background-size: 400% 400%;
animation: bg-gradient 22s ease infinite;
/* Optional: keeps bg fixed while content scrolls */
background-attachment: fixed;
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
body {
animation: none;
}
}
@keyframes bg-gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Optional readability veil; tweak or remove */
body::before {
content: "";
position: fixed;
inset: 0;
backdrop-filter: none;
background: rgba(255, 255, 255, 0.08); /* subtle wash */
pointer-events: none;
}
@layer components {
.wrapper {
@apply w-full max-w-7xl mx-auto px-4;
}
.section {
@apply py-16 md:py-24;
}
}

120
src/lib/Head.jsx Normal file
View File

@@ -0,0 +1,120 @@
import { useEffect } from "react";
function upsertMeta(selector, attrs) {
let el = document.head.querySelector(selector);
if (!el) {
el = document.createElement("meta");
el.setAttribute("data-managed", "head");
document.head.appendChild(el);
}
Object.entries(attrs).forEach(([k, v]) => {
if (v != null) el.setAttribute(k, v);
});
return el;
}
function upsertLink(rel, href) {
let el = document.head.querySelector(`link[rel="${rel}"]`);
if (!el) {
el = document.createElement("link");
el.setAttribute("rel", rel);
el.setAttribute("data-managed", "head");
document.head.appendChild(el);
}
el.setAttribute("href", href);
return el;
}
function upsertScript(id, type, text) {
let el = document.getElementById(id);
if (!el) {
el = document.createElement("script");
el.id = id;
el.type = type;
el.setAttribute("data-managed", "head");
document.head.appendChild(el);
}
el.textContent = text;
return el;
}
export default function Head({
title,
description,
canonical,
og = {},
twitter = {},
jsonLd, // object or array of objects
}) {
useEffect(() => {
const created = [];
if (title) {
const prev = document.title;
document.title = title;
created.push(() => (document.title = prev));
}
if (description) {
const el = upsertMeta('meta[name="description"]', {
name: "description",
content: description,
});
created.push(() => el.remove());
}
if (canonical) {
const el = upsertLink("canonical", canonical);
created.push(() => el.remove());
}
// Open Graph
const ogMap = {
"og:type": og.type || "website",
"og:title": og.title || title,
"og:description": og.description || description,
"og:url": og.url || canonical,
"og:image": og.image,
"og:site_name": og.siteName,
};
Object.entries(ogMap).forEach(([prop, content]) => {
if (!content) return;
const el = upsertMeta(`meta[property="${prop}"]`, {
property: prop,
content,
});
created.push(() => el.remove());
});
// Twitter
const twMap = {
"twitter:card": twitter.card || "summary_large_image",
"twitter:title": twitter.title || title,
"twitter:description": twitter.description || description,
"twitter:image": twitter.image || og.image,
};
Object.entries(twMap).forEach(([name, content]) => {
if (!content) return;
const el = upsertMeta(`meta[name="${name}"]`, { name, content });
created.push(() => el.remove());
});
// JSON-LD
if (jsonLd) {
const items = Array.isArray(jsonLd) ? jsonLd : [jsonLd];
const el = upsertScript(
"jsonld-webpage",
"application/ld+json",
JSON.stringify(items.length === 1 ? items[0] : items)
);
created.push(() => el.remove());
}
return () => created.reverse().forEach((fn) => fn());
}, [
title,
description,
canonical,
JSON.stringify(og),
JSON.stringify(twitter),
JSON.stringify(jsonLd),
]);
return null;
}

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

59
src/pages/About.jsx Normal file
View File

@@ -0,0 +1,59 @@
import React from "react";
import AboutCard from "../components/AboutCard";
import AboutDetails from "../components/AboutDetails";
import Head from "../lib/Head";
const SITE_URL = "https://priscy-orcin.vercel.app";
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
const TITLE = "About — Priscy Designs";
const DESCRIPTION =
"Explore UI/UX, full-stack development, branding, and product design projects.";
export default function About() {
const jsonLdWebPage = {
"@context": "https://schema.org",
"@type": "WebPage",
name: TITLE,
url: `${SITE_URL}/about`,
description: DESCRIPTION,
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
publisher: {
"@type": "Organization",
name: "Priscy Designs",
logo: { "@type": "ImageObject", url: LOGO_URL },
},
};
return (
<>
<Head
title={TITLE}
description={DESCRIPTION}
canonical={SITE_URL}
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
twitter={{ image: COVER_URL }}
jsonLd={jsonLdWebPage}
/>
<section className="lg:p-0 flex flex-col gap-5">
<div className="p-0 flex flex-col gap-5 mt-14 lg:mt-16">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Left column: sticky card */}
<div className="lg:col-span-1">
<div className="sticky top-6">
{" "}
{/* adjust top-6 for offset under your navbar */}
<AboutCard />
</div>
</div>
{/* Right columns: long scrolling content */}
<div className="lg:col-span-2">
<AboutDetails />
</div>
</div>
</div>
</section>
</>
);
}

136
src/pages/Blog.jsx Normal file
View File

@@ -0,0 +1,136 @@
import React, { useEffect, useMemo } from "react";
import { Link, useSearchParams } from "react-router-dom";
import posts from "../data/blog.json";
import Head from "../lib/Head";
const SITE_URL = "https://priscy-orcin.vercel.app";
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
const TITLE = "Blog — Priscy Designs";
const DESCRIPTION =
"Explore UI/UX, full-stack development, branding, and product design projects.";
const PER_PAGE = 6;
export default function Blog() {
const jsonLdWebPage = {
"@context": "https://schema.org",
"@type": "WebPage",
name: TITLE,
url: `${SITE_URL}/blog`,
description: DESCRIPTION,
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
publisher: {
"@type": "Organization",
name: "Priscy Designs",
logo: { "@type": "ImageObject", url: LOGO_URL },
},
};
const [params, setParams] = useSearchParams();
const pageFromUrl = Number(params.get("page")) || 1;
const totalPages = Math.max(1, Math.ceil(posts.length / PER_PAGE));
const page = Math.min(Math.max(1, pageFromUrl), totalPages);
// Keep URL clean & valid if someone types ?page=999 or ?page=abc
useEffect(() => {
if (page !== pageFromUrl)
setParams({ page: String(page) }, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageFromUrl]);
// Slice the posts for this page
const pagePosts = useMemo(() => {
const start = (page - 1) * PER_PAGE;
return posts.slice(start, start + PER_PAGE);
}, [page]);
// Scroll to top on page change
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, [page]);
const goto = (p) => setParams({ page: String(p) });
return (
<>
<Head
title={TITLE}
description={DESCRIPTION}
canonical={SITE_URL}
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
twitter={{ image: COVER_URL }}
jsonLd={jsonLdWebPage}
/>
<div className="mt-14 lg:mt-16 bg-[var(--card)] text-[var(--text)] rounded-2xl p-3">
<h1 className="text-3xl py-3">Recent Posts</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{pagePosts.map((p) => (
<article
key={p.id}
className="rounded-xl border border-gray-200 dark:border-neutral-800 overflow-hidden flex flex-col justify-between"
>
<img
src={p.coverImage}
alt={p.title}
className="w-full h-48 object-cover"
/>
<div className="p-4">
<p className="text-xs text-gray-500">
{p.category} {new Date(p.date).toLocaleDateString()}
</p>
<h3 className="text-lg font-semibold mt-1">{p.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-3 mt-1">
{p.excerpt}
</p>
<Link
to={`/blog/${p.slug}`}
className="inline-block mt-3 text-blue-600 hover:underline"
>
See More
</Link>
</div>
</article>
))}
</div>
{/* Pagination */}
<nav
className="mt-8 flex items-center justify-center gap-2"
aria-label="Pagination"
>
<button
onClick={() => goto(page - 1)}
disabled={page === 1}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-800 disabled:opacity-50"
>
Prev
</button>
{/* Page numbers (simple: show all; you can window this if pages get big) */}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => goto(p)}
aria-current={p === page ? "page" : undefined}
className={`px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-800
${p === page ? "bg-blue-600 text-white border-blue-600" : ""}`}
>
{p}
</button>
))}
<button
onClick={() => goto(page + 1)}
disabled={page === totalPages}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-neutral-800 disabled:opacity-50"
>
Next
</button>
</nav>
</div>
</>
);
}

335
src/pages/BlogDetail.jsx Normal file
View File

@@ -0,0 +1,335 @@
// src/pages/BlogDetail.jsx
import { useMemo } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import posts from "../data/blog.json";
import Head from "../lib/Head";
function seededRandom(seed) {
let h = 2166136261 >>> 0;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return () => {
h += 0x6d2b79f5;
let t = Math.imul(h ^ (h >>> 15), 1 | h);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function BlogDetail() {
const { slug } = useParams();
const navigate = useNavigate();
// find post + index (always called)
const { post, index } = useMemo(() => {
const i = posts.findIndex((p) => p.slug === slug);
return i !== -1 ? { post: posts[i], index: i } : { post: null, index: -1 };
}, [slug]);
// SEO values
const origin =
typeof window !== "undefined"
? window.location.origin
: "https://your-domain.com";
const canonical = post
? `${origin}/blog/${post.slug}`
: `${origin}/blog/not-found`;
const title = post
? `${post.title} — Priscy Designs`
: "Post not found — Priscy Designs";
const description = post
? (post.excerpt || post.body || "").toString().slice(0, 160)
: "The requested blog post could not be found.";
const cover = post?.coverImage || "/og-cover.jpg";
const logo = "/logo.png";
const jsonLdWebPage = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post?.title || "Post not found",
description: description,
image: cover,
url: canonical,
datePublished: post?.date,
author: post?.author ? { "@type": "Person", name: post.author } : undefined,
publisher: {
"@type": "Organization",
name: "Priscy Designs",
logo: { "@type": "ImageObject", url: logo },
},
};
// related (always called)
const related = useMemo(() => {
if (!post) return [];
const pool = posts.filter((p) => p !== post);
const rng = seededRandom(post.slug);
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool.slice(0, 4);
}, [post]);
// prev/next (compute early)
const prev =
index === -1 ? null : posts[(index - 1 + posts.length) % posts.length];
const next = index === -1 ? null : posts[(index + 1) % posts.length];
if (!post) {
return (
<>
<Head
title={title}
description={description}
canonical={canonical}
og={{
url: canonical,
image: cover,
siteName: "Priscy Designs",
title,
description,
}}
twitter={{
card: "summary_large_image",
image: cover,
title,
description,
}}
jsonLd={jsonLdWebPage}
/>
<main className="py-12" role="main">
<h1 className="text-2xl font-semibold mb-2 text-center">
Post not found
</h1>
<p className="text-center text-gray-600 mb-6">
We couldnt find a blog post at this URL.
</p>
<div className="text-center">
<button
onClick={() => navigate(-1)}
className="text-blue-600 hover:underline"
>
Go Back
</button>
</div>
</main>
</>
);
}
const toSlug = (p) => `/blog/${p.slug}`;
const date = new Date(post.date);
const dateStr = date.toLocaleDateString();
return (
<>
<Head
title={title}
description={description}
canonical={canonical}
og={{
url: canonical,
image: cover,
siteName: "Priscy Designs",
title,
description,
}}
twitter={{
card: "summary_large_image",
image: cover,
title,
description,
}}
jsonLd={jsonLdWebPage}
/>
<main
id="main"
role="main"
className="mt-14 lg:mt-16"
aria-labelledby="post-title"
>
<section className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,28%)]">
{/* Main article */}
<article
className="space-y-6 bg-[var(--card)] text-[var(--text)] rounded-2xl p-3 h-fit"
itemScope
itemType="https://schema.org/BlogPosting"
aria-labelledby="post-title"
>
{/* Breadcrumb */}
<nav aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-sm text-blue-600">
<li>
<Link to="/blog" className="hover:underline">
Blog
</Link>
</li>
<li aria-hidden="true">/</li>
<li
aria-current="page"
className="text-gray-600 dark:text-gray-300 truncate max-w-[60ch]"
>
{post.title}
</li>
</ol>
</nav>
{/* Hero */}
<figure className="rounded-2xl overflow-hidden border border-gray-200 dark:border-neutral-800">
<img
src={cover}
alt={post.title}
className="w-full h-96 object-cover"
decoding="async"
fetchpriority="high"
/>
{post.excerpt && (
<figcaption className="sr-only">{post.excerpt}</figcaption>
)}
</figure>
{/* Header */}
<header className="flex flex-col gap-2">
<p className="text-sm text-gray-500">
<span className="sr-only">Category: </span>
{post.category} {" "}
<time dateTime={date.toISOString()}>{dateStr}</time>
</p>
<h1
id="post-title"
className="text-3xl font-bold"
itemProp="headline"
>
{post.title}
</h1>
{post.excerpt && (
<p
className="text-gray-700 dark:text-gray-300"
itemProp="description"
>
{post.excerpt}
</p>
)}
</header>
{/* Body */}
<div
className="prose dark:prose-invert max-w-none"
itemProp="articleBody"
>
<p>{post.body}</p>
{Array.isArray(post.images) && post.images.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 mt-4">
{post.images.map((src, i) => (
<img
key={i}
src={src}
alt={`${post.title} image ${i + 1}`}
className="w-full h-64 object-cover rounded-xl border border-gray-200 dark:border-neutral-800"
loading="lazy"
decoding="async"
/>
))}
</div>
)}
</div>
{/* External link */}
{post.url && (
<p>
<a
href={post.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 hover:underline"
aria-label={`Continue reading ${post.title} on external site`}
>
Continue reading
</a>
</p>
)}
{/* Prev / Next */}
{prev && next && (
<nav
className="mt-8 flex items-center justify-between gap-4 border-t pt-6 border-gray-200 dark:border-neutral-800"
aria-label="Post navigation"
>
<Link
to={toSlug(prev)}
className="group inline-flex items-center gap-2 text-blue-600 hover:underline"
aria-label={`Previous post: ${prev.title}`}
>
<span
aria-hidden="true"
className="translate-x-0 group-hover:-translate-x-0.5 transition"
>
</span>
<span className="truncate max-w-[16rem]">{prev.title}</span>
</Link>
<Link
to={toSlug(next)}
className="group inline-flex items-center gap-2 text-blue-600 hover:underline"
aria-label={`Next post: ${next.title}`}
>
<span className="truncate max-w-[16rem]">{next.title}</span>
<span
aria-hidden="true"
className="translate-x-0 group-hover:translate-x-0.5 transition"
>
</span>
</Link>
</nav>
)}
</article>
{/* Related */}
<aside
className="lg:sticky lg:top-24 h-fit space-y-4 bg-[var(--card)] text-[var(--text)] p-3 rounded-2xl"
aria-labelledby="more-posts-heading"
>
<h2 id="more-posts-heading" className="text-lg font-semibold">
More Posts
</h2>
<ul className="grid gap-4">
{related.map((p) => (
<li key={p.id + "-rel"} className="list-none">
<article aria-labelledby={`rel-${p.id}-title`}>
<Link
to={toSlug(p)}
className="block rounded-xl border border-gray-200 dark:border-neutral-800 overflow-hidden hover:shadow-sm transition"
aria-label={`Read ${p.title}`}
>
<img
src={p.coverImage}
alt={p.title}
className="w-full h-32 object-cover"
loading="lazy"
decoding="async"
/>
<div className="p-3">
<p className="text-xs text-gray-500">{p.category}</p>
<h3
id={`rel-${p.id}-title`}
className="text-sm font-medium line-clamp-2"
>
{p.title}
</h3>
</div>
</Link>
</article>
</li>
))}
</ul>
</aside>
</section>
</main>
</>
);
}

58
src/pages/Contact.jsx Normal file
View File

@@ -0,0 +1,58 @@
import React from "react";
import AboutCard from "../components/AboutCard";
import ContactDetails from "../components/ContactDetails";
import Head from "../lib/Head";
const SITE_URL = "https://priscy-orcin.vercel.app";
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
const TITLE = "Services — Priscy Designs";
const DESCRIPTION =
"Explore UI/UX, full-stack development, branding, and product design projects.";
export default function Contact() {
const jsonLdWebPage = {
"@context": "https://schema.org",
"@type": "WebPage",
name: TITLE,
url: `${SITE_URL}/services`,
description: DESCRIPTION,
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
publisher: {
"@type": "Organization",
name: "Priscy Designs",
logo: { "@type": "ImageObject", url: LOGO_URL },
},
};
return (
<>
<Head
title={TITLE}
description={DESCRIPTION}
canonical={SITE_URL}
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
twitter={{ image: COVER_URL }}
jsonLd={jsonLdWebPage}
/>
<section className="lg:p-0 flex flex-col gap-5">
<div className="p-0 flex flex-col gap-5 mt-14 lg:mt-16">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Left column: sticky card */}
<div className="lg:col-span-1">
<div className="sticky top-6">
{" "}
{/* adjust top-6 for offset under your navbar */}
<AboutCard />
</div>
</div>
{/* Right columns: long scrolling content */}
<div className="lg:col-span-2">
<ContactDetails />
</div>
</div>
</div>
</section>
</>
);
}

133
src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,133 @@
import React from "react";
import AboutCard from "../components/AboutCard";
import WorkExperience from "../components/WorkExperience";
import ExpertArea from "../components/ExpertArea";
import ServiceOffer from "../components/ServiceOffer";
import WorkTogetherCard from "../components/WorkTogetherCard";
import ProjectCards from "../components/ProjectCards";
import GallerySlider from "../components/GallerySlider";
import Head from "../lib/Head";
// Hero/gallery images with descriptive alt
const images = [
{
src: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1600&q=80&auto=format&fit=crop",
alt: "City skyline at dusk with reflections on water",
},
{
src: "https://images.unsplash.com/photo-1519681393784-d120267933ba?w=1600&q=80&auto=format&fit=crop",
alt: "Abstract gradient texture in vibrant colors",
},
{
src: "https://images.unsplash.com/photo-1496307042754-b4aa456c4a2d?w=1600&q=80&auto=format&fit=crop",
alt: "Snow-capped mountains surrounding a calm lake",
},
];
const SITE_URL = "https://priscy-orcin.vercel.app";
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
const TITLE = "Priscy Designs — Portfolio & Services";
const DESCRIPTION =
"Explore UI/UX, full-stack development, branding, and product design projects.";
export default function Home() {
const jsonLdWebPage = {
"@context": "https://schema.org",
"@type": "WebPage",
name: TITLE,
url: SITE_URL,
description: DESCRIPTION,
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
publisher: {
"@type": "Organization",
name: "Priscy Designs",
logo: { "@type": "ImageObject", url: LOGO_URL },
},
};
return (
<>
<Head
title={TITLE}
description={DESCRIPTION}
canonical={SITE_URL}
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
twitter={{ image: COVER_URL }}
jsonLd={jsonLdWebPage}
/>
{/* Skip link for keyboard users */}
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:bg-white focus:text-black focus:px-3 focus:py-2 rounded"
>
Skip to content
</a>
{/* Main landmark */}
<main id="main" className="lg:p-0 flex flex-col gap-5" role="main">
{/* Page heading (only one H1 per page) */}
<header className="p-0 mt-14 lg:mt-16">
<h1 className="sr-only">Priscy Designs Portfolio and Services</h1>
</header>
{/* Intro grid: About + Featured Projects */}
<section
aria-labelledby="featured-projects-heading"
className="p-0 flex flex-col gap-5"
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<aside className="lg:col-span-1" aria-label="About Priscy">
<div className="sticky top-6">
<AboutCard />
</div>
</aside>
<div className="col-span-2">
<h2
id="featured-projects-heading"
className="text-2xl font-semibold mb-2"
>
Featured Projects
</h2>
{/* 4 items per page? set pagination prop accordingly */}
<ProjectCards pagination={false} />
</div>
</div>
</section>
{/* Gallery + Work Experience + Expertise */}
<section
aria-labelledby="work-expertise-heading"
className="grid lg:grid-cols-3 gap-5"
>
<h2 id="work-expertise-heading" className="sr-only">
Work, Expertise, and Gallery
</h2>
{/* The slider already has ARIA from earlier; ensure it has a label prop if you add one */}
<div aria-label="Recent work gallery">
<GallerySlider images={images} interval={4500} />
</div>
<div aria-label="Work experience timeline">
<WorkExperience />
</div>
<div aria-label="Expert areas">
<ExpertArea />
</div>
</section>
{/* Services & CTA */}
<section
aria-labelledby="services-cta-heading"
className="flex flex-col lg:grid lg:grid-cols-3 gap-5"
>
<ServiceOffer />
<WorkTogetherCard />
</section>
</main>
</>
);
}

59
src/pages/Portfolio.jsx Normal file
View File

@@ -0,0 +1,59 @@
import React from "react";
import AboutCard from "../components/AboutCard";
import ProjectDetails from "../components/ProjectDetails";
import Head from "../lib/Head";
const SITE_URL = "https://priscy-orcin.vercel.app";
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
const TITLE = "Portfolio — Priscy Designs";
const DESCRIPTION =
"Explore UI/UX, full-stack development, branding, and product design projects.";
export default function Portfolio() {
const jsonLdWebPage = {
"@context": "https://schema.org",
"@type": "WebPage",
name: TITLE,
url: `${SITE_URL}/portfolio`,
description: DESCRIPTION,
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
publisher: {
"@type": "Organization",
name: "Priscy Designs",
logo: { "@type": "ImageObject", url: LOGO_URL },
},
};
return (
<>
<Head
title={TITLE}
description={DESCRIPTION}
canonical={SITE_URL}
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
twitter={{ image: COVER_URL }}
jsonLd={jsonLdWebPage}
/>
<section className="lg:p-0 flex flex-col gap-5">
<div className="p-0 flex flex-col gap-5 mt-14 lg:mt-16">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Left column: sticky card */}
<div className="lg:col-span-1">
<div className="sticky top-6">
{" "}
{/* adjust top-6 for offset under your navbar */}
<AboutCard />
</div>
</div>
{/* Right columns: long scrolling content */}
<div className="lg:col-span-2">
<ProjectDetails />
</div>
</div>
</div>
</section>
</>
);
}

320
src/pages/ProductDetail.jsx Normal file
View File

@@ -0,0 +1,320 @@
import { useMemo } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import products from "../data/projects.json";
import Head from "../lib/Head";
function slugify(s) {
return (s || "")
.toString()
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "");
}
// tiny seeded RNG so "random" is stable per product
function seededRandom(seed) {
let h = 2166136261 >>> 0;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return () => {
h += 0x6d2b79f5;
let t = Math.imul(h ^ (h >>> 15), 1 | h);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function ProductDetail() {
const { slug } = useParams();
const navigate = useNavigate();
// find product + index (hook always called)
const { product, index } = useMemo(() => {
const bySlug = products.findIndex((p) => p.slug === slug);
if (bySlug !== -1) return { product: products[bySlug], index: bySlug };
const byTitle = products.findIndex((p) => slugify(p.title) === slug);
if (byTitle !== -1) return { product: products[byTitle], index: byTitle };
return { product: null, index: -1 };
}, [slug]);
// related list (hook always called; returns [] when no product)
const related = useMemo(() => {
if (!product) return [];
const pool = products.filter((p) => p !== product);
const rng = seededRandom(product.slug ?? slugify(product.title));
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool.slice(0, 4);
}, [product]);
// prev/next (compute even if product is null; values only used when product exists)
const prevIndex =
index === -1 ? -1 : (index - 1 + products.length) % products.length;
const nextIndex = index === -1 ? -1 : (index + 1) % products.length;
const prev = prevIndex === -1 ? null : products[prevIndex];
const next = nextIndex === -1 ? null : products[nextIndex];
const toSlug = (p) => `/portfolio/${p.slug ?? slugify(p.title)}`;
// now its safe to conditionally return
if (!product) {
return (
<>
<Head
title="Project not found — Priscy Designs"
description="The requested project could not be found."
/>
<div className="py-12 text-center">
<h1 className="text-2xl font-semibold mb-2">Product not found</h1>
<p className="text-gray-600 mb-6">
We couldnt find a product with that URL.
</p>
<button
onClick={() => navigate(-1)}
className="text-blue-600 hover:underline"
>
Go Back
</button>
</div>
</>
);
}
/* ---------- ✅ Build SEO values from the product ---------- */
const prodSlug = product.slug ?? slugify(product.title); //replace with your actual product slug/url
const origin =
typeof window !== "undefined"
? window.location.origin
: "https://your-domain.com";
const canonical = `${origin}/portfolio/${prodSlug}`;
const title = `${product.title} — Priscy Designs`;
const description =
product.body?.slice(0, 160) || "Project case study from Priscy Designs.";
const cover = product.coverImage ?? product.images?.[0] ?? "/og-cover.jpg";
const logo = "/logo.png";
const jsonLdWebPage = {
"@context": "https://schema.org",
"@type": "WebPage",
name: title,
url: canonical,
description,
primaryImageOfPage: { "@type": "ImageObject", url: cover },
publisher: {
"@type": "Organization",
name: "Priscy Designs",
logo: { "@type": "ImageObject", url: logo },
},
};
return (
<>
<Head
title={title}
description={description}
canonical={canonical}
og={{
url: canonical,
image: cover,
siteName: "Priscy Designs",
title,
description,
}}
twitter={{
image: cover,
title,
description,
card: "summary_large_image",
}}
jsonLd={jsonLdWebPage}
/>
<main
id="main"
role="main"
className="mt-14 lg:mt-16"
aria-labelledby="project-title"
>
<section className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,28%)]">
{/* Main project */}
<article
className="space-y-6 bg-[var(--card)] text-[var(--text)] rounded-2xl p-3 h-fit"
itemScope
itemType="https://schema.org/CreativeWork"
aria-labelledby="project-title"
>
{/* Breadcrumb */}
<nav aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-sm text-blue-600">
<li>
<Link to="/portfolio" className="hover:underline">
Portfolio
</Link>
</li>
<li aria-hidden="true">/</li>
<li
aria-current="page"
className="text-gray-600 dark:text-gray-300 truncate max-w-[60ch]"
>
{product.title}
</li>
</ol>
</nav>
{/* Hero */}
<figure className="rounded-2xl overflow-hidden border border-gray-200 dark:border-neutral-800">
<img
src={product.coverImage ?? product.images?.[0]}
alt={product.title}
className="w-full h-72 object-cover"
decoding="async"
fetchpriority="high"
itemProp="image"
/>
{description && (
<figcaption className="sr-only">{description}</figcaption>
)}
</figure>
{/* Header */}
<header className="flex flex-col gap-2">
<p className="text-sm">
<span className="sr-only">Category: </span>
{product.category}
</p>
<h1
id="project-title"
className="text-3xl font-bold"
itemProp="name"
>
{product.title}
</h1>
</header>
{/* Body */}
<div className="text-lg dark:text-gray-300" itemProp="description">
<p>{product.body}</p>
</div>
{/* Gallery */}
{Array.isArray(product.images) && product.images.length > 0 && (
<div
className="grid gap-4 sm:grid-cols-2"
aria-label="Project gallery"
>
{product.images.map((src, i) => (
<img
key={i}
src={src}
alt={`${product.title} image ${i + 1}`}
className="w-full h-64 object-cover rounded-xl border border-gray-200 dark:border-neutral-800"
loading="lazy"
decoding="async"
/>
))}
</div>
)}
{/* External URL */}
{product.url && (
<p>
<a
href={product.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 hover:underline"
aria-label={`Visit external project page for ${product.title}`}
>
Visit Project
</a>
</p>
)}
{/* Prev / Next */}
{prev && next && (
<nav
className="mt-8 flex items-center justify-between gap-4 border-t pt-6 border-gray-200 dark:border-neutral-800"
aria-label="Project navigation"
>
<Link
to={toSlug(prev)}
className="group inline-flex items-center gap-2 text-blue-600 hover:underline"
aria-label={`Previous project: ${prev.title}`}
>
<span
aria-hidden="true"
className="translate-x-0 group-hover:-translate-x-0.5 transition"
>
</span>
<span className="truncate max-w-[16rem]">{prev.title}</span>
</Link>
<Link
to={toSlug(next)}
className="group inline-flex items-center gap-2 text-blue-600 hover:underline"
aria-label={`Next project: ${next.title}`}
>
<span className="truncate max-w-[16rem]">{next.title}</span>
<span
aria-hidden="true"
className="translate-x-0 group-hover:translate-x-0.5 transition"
>
</span>
</Link>
</nav>
)}
</article>
{/* Related projects */}
<aside
className="lg:sticky lg:top-24 h-fit space-y-4 bg-[var(--card)] text-[var(--text)] p-3 rounded-2xl"
aria-labelledby="more-projects-heading"
>
<h2 id="more-projects-heading" className="text-lg font-semibold">
More Projects
</h2>
<ul className="grid gap-4">
{related.map((p) => (
<li key={(p.id ?? p.title) + "-rel"} className="list-none">
<article
aria-labelledby={`rel-${p.id ?? slugify(p.title)}-title`}
>
<Link
to={toSlug(p)}
className="block rounded-xl border border-gray-200 dark:border-neutral-800 overflow-hidden hover:shadow-sm transition"
aria-label={`Open project ${p.title}`}
>
<img
src={p.coverImage ?? p.images?.[0]}
alt={p.title}
className="w-full h-32 object-cover"
loading="lazy"
decoding="async"
/>
<div className="p-3">
<p className="text-xs text-gray-500">{p.category}</p>
<h3
id={`rel-${p.id ?? slugify(p.title)}-title`}
className="text-sm font-medium line-clamp-2"
>
{p.title}
</h3>
</div>
</Link>
</article>
</li>
))}
</ul>
</aside>
</section>
</main>
</>
);
}

59
src/pages/Services.jsx Normal file
View File

@@ -0,0 +1,59 @@
import React from "react";
import AboutCard from "../components/AboutCard";
import ServiceDetails from "../components/ServiceDetails";
import Head from "../lib/Head";
const SITE_URL = "https://priscy-orcin.vercel.app";
const COVER_URL = "https://priscy-orcin.vercel.app/og-cover.jpg";
const LOGO_URL = "https://priscy-orcin.vercel.app/logo.png";
const TITLE = "Services — Priscy Designs";
const DESCRIPTION =
"Explore UI/UX, full-stack development, branding, and product design projects.";
export default function Services() {
const jsonLdWebPage = {
"@context": "https://schema.org",
"@type": "WebPage",
name: TITLE,
url: `${SITE_URL}/services`,
description: DESCRIPTION,
primaryImageOfPage: { "@type": "ImageObject", url: COVER_URL },
publisher: {
"@type": "Organization",
name: "Priscy Designs",
logo: { "@type": "ImageObject", url: LOGO_URL },
},
};
return (
<>
<Head
title={TITLE}
description={DESCRIPTION}
canonical={SITE_URL}
og={{ url: SITE_URL, image: COVER_URL, siteName: "Priscy Designs" }}
twitter={{ image: COVER_URL }}
jsonLd={jsonLdWebPage}
/>
<section className="lg:p-0 flex flex-col gap-5">
<div className="p-0 flex flex-col gap-5 mt-14 lg:mt-16">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Left column: sticky card */}
<div className="lg:col-span-1">
<div className="sticky top-6">
{" "}
{/* adjust top-6 for offset under your navbar */}
<AboutCard />
</div>
</div>
{/* Right columns: long scrolling content */}
<div className="lg:col-span-2">
<ServiceDetails />
</div>
</div>
</div>
</section>
</>
);
}

8
vite.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [tailwindcss(), react()],
});