first commit
24
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
1
public/assets/arrow-right.svg
Normal 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 |
1
public/assets/arrow-up-right.svg
Normal 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
@@ -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
|
After Width: | Height: | Size: 203 KiB |
1
public/assets/briefcase.svg
Normal 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
@@ -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
@@ -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 |
1
public/assets/facebook.svg
Normal 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
@@ -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
@@ -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 |
1
public/assets/instagram.svg
Normal 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
@@ -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 |
1
public/assets/linkedin.svg
Normal 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 |
1
public/assets/message.svg
Normal 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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 197 KiB |
1
public/assets/user.svg
Normal 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
|
After Width: | Height: | Size: 181 KiB |
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Sitemap: https://priscy-orcin.vercel.app/sitemap.xml
|
||||||
1
public/vite.svg
Normal 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
@@ -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;
|
||||||
106
src/components/AboutCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/AboutDetails.jsx
Normal 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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/components/Certifications.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/ContactDetails.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/ContactForm.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/ExpertArea.jsx
Normal 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
@@ -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
@@ -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">©</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/components/GallerySlider.jsx
Normal 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 won’t parse dynamic value; we’ll 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
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/components/ProjectCards.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/ProjectDetails.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/ServiceDetails.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/ServiceOffer.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
src/components/Testimonials.jsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/WorkExperience.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/WorkTogetherCard.jsx
Normal 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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/WorkTogetherSlider.jsx
Normal 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">Let’s 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
@@ -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 Don’t 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
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
34
src/data/certifications.json
Normal 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
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
50
src/data/testimonials.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 couldn’t 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
@@ -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
@@ -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
@@ -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
@@ -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 it’s 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 couldn’t 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
@@ -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
@@ -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()],
|
||||||
|
});
|
||||||