This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
.github
|
||||
node_modules
|
||||
.next
|
||||
.turbo
|
||||
.env*.local
|
||||
.DS_Store
|
||||
README.md
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: "https://biztaghavi.com",
|
||||
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || "https://biztaghavi.com",
|
||||
generateRobotsTxt: true,
|
||||
generateIndexSitemap: false,
|
||||
changefreq: "weekly",
|
||||
priority: 0.7,
|
||||
exclude: ["/studio/*", "/api/*"],
|
||||
exclude: ["/studio/*", "/api/*", "/en/studio/*"],
|
||||
alternateRefs: [
|
||||
{ href: "https://biztaghavi.com", hreflang: "fa" },
|
||||
{ href: "https://biztaghavi.com/en", hreflang: "en" },
|
||||
{ href: "https://biztaghavi.com", hreflang: "x-default" },
|
||||
],
|
||||
robotsTxtOptions: {
|
||||
policies: [
|
||||
{ userAgent: "*", allow: "/" },
|
||||
{ userAgent: "*", disallow: ["/studio", "/api"] },
|
||||
],
|
||||
additionalSitemaps: [],
|
||||
},
|
||||
transform: async (config, path) => {
|
||||
// Boost priority for key pages
|
||||
const highPriority = ["/", "/en", "/about", "/en/about", "/writing", "/en/writing"];
|
||||
const priority = highPriority.includes(path) ? 1.0 : config.priority;
|
||||
return {
|
||||
loc: path,
|
||||
changefreq: config.changefreq,
|
||||
priority,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
alternateRefs: config.alternateRefs ?? [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,7 +9,30 @@ const nextConfig: NextConfig = {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "cdn.sanity.io" },
|
||||
],
|
||||
formats: ["image/avif", "image/webp"],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
|
||||
minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
|
||||
},
|
||||
experimental: {
|
||||
optimizePackageImports: ["lucide-react", "framer-motion", "@sanity/image-url"],
|
||||
},
|
||||
compress: true,
|
||||
poweredByHeader: false,
|
||||
headers: async () => [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/_next/static/(.*)",
|
||||
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
"@sanity/image-url": "^1.1.0",
|
||||
"@portabletext/react": "^3.1.0",
|
||||
"sugar-high": "^0.9.0",
|
||||
"next-sitemap": "^4.2.3"
|
||||
"next-sitemap": "^4.2.3",
|
||||
"resend": "^4.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["@parcel/watcher", "@swc/core"]
|
||||
|
||||
226
pnpm-lock.yaml
generated
226
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@portabletext/react':
|
||||
specifier: ^3.1.0
|
||||
version: 3.2.4(react@19.2.4)
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -26,6 +29,9 @@ importers:
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@sanity/image-url':
|
||||
specifier: ^1.1.0
|
||||
version: 1.2.0
|
||||
@@ -59,6 +65,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
resend:
|
||||
specifier: ^4.0.0
|
||||
version: 4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
sanity:
|
||||
specifier: ^3.0.0
|
||||
version: 3.99.0(@emotion/is-prop-valid@1.4.0)(@types/node@20.19.39)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@10.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styled-components@6.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(yaml@2.8.3)
|
||||
@@ -1717,6 +1726,32 @@ packages:
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-accordion@1.2.12':
|
||||
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12':
|
||||
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collection@1.1.7':
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
peerDependencies:
|
||||
@@ -1879,6 +1914,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11':
|
||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-separator@1.1.8':
|
||||
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
|
||||
peerDependencies:
|
||||
@@ -1910,6 +1958,19 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13':
|
||||
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
@@ -1977,6 +2038,13 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@react-email/render@1.1.2':
|
||||
resolution: {integrity: sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
|
||||
'@rexxars/react-json-inspector@9.0.1':
|
||||
resolution: {integrity: sha512-4uZ4RnrVoOGOShIKKcPoF+qhwDCZJsPPqyoEoW/8HRdzNknN9Q2yhlbEgTX1lMZunF1fv7iHzAs+n1vgIgfg/g==}
|
||||
peerDependencies:
|
||||
@@ -2379,6 +2447,9 @@ packages:
|
||||
'@schummar/icu-type-parser@1.21.5':
|
||||
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
|
||||
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||
|
||||
'@sentry-internal/browser-utils@8.55.1':
|
||||
resolution: {integrity: sha512-SipXiwVhJrxzy3/4kf+YIFmpYlLKtGSRD+er7SBCcuSBtv31Fee8IXMDvk+bq24gRXxyjOLUmT//GGXjy2LL6w==}
|
||||
engines: {node: '>=14.18'}
|
||||
@@ -3524,6 +3595,10 @@ packages:
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
deepmerge@4.3.1:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
defaults@1.0.4:
|
||||
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
|
||||
|
||||
@@ -3863,6 +3938,9 @@ packages:
|
||||
fast-content-type-parse@3.0.0:
|
||||
resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==}
|
||||
|
||||
fast-deep-equal@2.0.1:
|
||||
resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -4221,6 +4299,13 @@ packages:
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
html-to-text@9.0.5:
|
||||
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -4638,6 +4723,9 @@ packages:
|
||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||
engines: {node: '>= 0.6.3'}
|
||||
|
||||
leac@0.6.0:
|
||||
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
|
||||
|
||||
leven@3.1.0:
|
||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5236,6 +5324,9 @@ packages:
|
||||
parse5@8.0.1:
|
||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||
|
||||
parseley@0.12.1:
|
||||
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
|
||||
|
||||
path-exists@3.0.0:
|
||||
resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -5273,6 +5364,9 @@ packages:
|
||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
peberminta@0.9.0:
|
||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||
|
||||
peek-stream@1.1.3:
|
||||
resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==}
|
||||
|
||||
@@ -5476,6 +5570,9 @@ packages:
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
react-promise-suspense@0.3.4:
|
||||
resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==}
|
||||
|
||||
react-refractor@2.2.0:
|
||||
resolution: {integrity: sha512-UvWkBVqH/2b9nkkkt4UNFtU3aY1orQfd4plPjx5rxbefy6vGajNHU9n+tv8CbykFyVirr3vEBfN2JTxyK0d36g==}
|
||||
peerDependencies:
|
||||
@@ -5604,6 +5701,10 @@ packages:
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resend@4.8.0:
|
||||
resolution: {integrity: sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -5735,6 +5836,9 @@ packages:
|
||||
resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==}
|
||||
hasBin: true
|
||||
|
||||
selderee@0.11.0:
|
||||
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
|
||||
|
||||
semver@5.7.2:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
hasBin: true
|
||||
@@ -8319,6 +8423,39 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
@@ -8468,6 +8605,23 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -8491,6 +8645,22 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -8540,6 +8710,14 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@react-email/render@1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
html-to-text: 9.0.5
|
||||
prettier: 3.8.3
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
react-promise-suspense: 0.3.4
|
||||
|
||||
'@rexxars/react-json-inspector@9.0.1(react@19.2.4)':
|
||||
dependencies:
|
||||
debounce: 1.2.1
|
||||
@@ -9102,6 +9280,11 @@ snapshots:
|
||||
|
||||
'@schummar/icu-type-parser@1.21.5': {}
|
||||
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
dependencies:
|
||||
domhandler: 5.0.3
|
||||
selderee: 0.11.0
|
||||
|
||||
'@sentry-internal/browser-utils@8.55.1':
|
||||
dependencies:
|
||||
'@sentry/core': 8.55.1
|
||||
@@ -10222,6 +10405,8 @@ snapshots:
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
|
||||
defaults@1.0.4:
|
||||
dependencies:
|
||||
clone: 1.0.4
|
||||
@@ -10731,6 +10916,8 @@ snapshots:
|
||||
|
||||
fast-content-type-parse@3.0.0: {}
|
||||
|
||||
fast-deep-equal@2.0.1: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
@@ -11113,6 +11300,21 @@ snapshots:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
|
||||
html-to-text@9.0.5:
|
||||
dependencies:
|
||||
'@selderee/plugin-htmlparser2': 0.11.0
|
||||
deepmerge: 4.3.1
|
||||
dom-serializer: 2.0.0
|
||||
htmlparser2: 8.0.2
|
||||
selderee: 0.11.0
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 4.5.0
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -11529,6 +11731,8 @@ snapshots:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
|
||||
leac@0.6.0: {}
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
||||
levn@0.4.1:
|
||||
@@ -12142,6 +12346,11 @@ snapshots:
|
||||
dependencies:
|
||||
entities: 8.0.0
|
||||
|
||||
parseley@0.12.1:
|
||||
dependencies:
|
||||
leac: 0.6.0
|
||||
peberminta: 0.9.0
|
||||
|
||||
path-exists@3.0.0: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
@@ -12168,6 +12377,8 @@ snapshots:
|
||||
|
||||
path-type@4.0.0: {}
|
||||
|
||||
peberminta@0.9.0: {}
|
||||
|
||||
peek-stream@1.1.3:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
@@ -12353,6 +12564,10 @@ snapshots:
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react-promise-suspense@0.3.4:
|
||||
dependencies:
|
||||
fast-deep-equal: 2.0.1
|
||||
|
||||
react-refractor@2.2.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -12515,6 +12730,13 @@ snapshots:
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resend@4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@react-email/render': 1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
@@ -12830,6 +13052,10 @@ snapshots:
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
|
||||
selderee@0.11.0:
|
||||
dependencies:
|
||||
parseley: 0.12.1
|
||||
|
||||
semver@5.7.2: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
@@ -1,9 +1,115 @@
|
||||
export default function AboutPage() {
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Github, Linkedin, Send, Twitter } from "lucide-react";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { AnimatedSection } from "@/components/shared/AnimatedSection";
|
||||
import { Timeline } from "@/components/about/Timeline";
|
||||
import { Values } from "@/components/about/Values";
|
||||
import { FocusAreas } from "@/components/about/FocusAreas";
|
||||
import { sanityFetch } from "@/lib/sanity/client";
|
||||
import { siteSettingsQuery } from "@/lib/sanity/queries";
|
||||
import type { SiteSettings } from "@/lib/sanity/types";
|
||||
import { groq } from "next-sanity";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "about" });
|
||||
return { title: t("title"), description: t("subtitle") };
|
||||
}
|
||||
|
||||
const timelineQuery = groq`
|
||||
*[_type == "timelineEvent"] | order(date desc) {
|
||||
title, date, description, icon, category
|
||||
}
|
||||
`;
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: Github, href: "https://github.com/alitaghavi", label: "GitHub" },
|
||||
{ icon: Linkedin, href: "https://linkedin.com/in/alitaghavi", label: "LinkedIn" },
|
||||
{ icon: Twitter, href: "https://twitter.com/alitaghavi", label: "Twitter/X" },
|
||||
{ icon: Send, href: "https://t.me/alitaghavi", label: "Telegram" },
|
||||
];
|
||||
|
||||
export default async function AboutPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
const [t, settings, timelineEvents] = await Promise.all([
|
||||
getTranslations({ locale, namespace: "about" }),
|
||||
sanityFetch<SiteSettings>(siteSettingsQuery),
|
||||
sanityFetch<unknown[]>(timelineQuery),
|
||||
]);
|
||||
|
||||
const values = t.raw("values") as { title: string; desc: string }[];
|
||||
const focusAreas = t.raw("focus_areas") as string[];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-32">
|
||||
<span className="font-mono text-xs text-accent">// about</span>
|
||||
<h1 className="mt-4 text-4xl font-bold text-text-primary">درباره علی تقوی</h1>
|
||||
<p className="mt-4 text-text-secondary">در حال آمادهسازی — Phase 3</p>
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
|
||||
<SectionHeader label="about" />
|
||||
|
||||
{/* Hero */}
|
||||
<AnimatedSection className="mb-20">
|
||||
<div className="grid gap-12 lg:grid-cols-[1fr_auto]">
|
||||
<div>
|
||||
<h1 className="mb-6 text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
{t("bio").split("\n\n").map((paragraph, i) => (
|
||||
<p key={i} className="text-base text-text-secondary leading-relaxed">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
{socialLinks.map(({ icon: Icon, href, label }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent hover:bg-accent-muted"
|
||||
>
|
||||
<Icon size={16} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current status card */}
|
||||
{settings?.currentStatus && (
|
||||
<div className="lg:w-72">
|
||||
<div className="rounded-xl border border-accent/20 bg-surface p-6">
|
||||
<div className="mb-3 font-mono text-xs text-accent">// {t("working_on")}</div>
|
||||
<p className="text-text-primary leading-relaxed">{settings.currentStatus}</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-success" />
|
||||
<span className="font-mono text-xs text-success">active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Values */}
|
||||
<AnimatedSection className="mb-20">
|
||||
<SectionHeader label={t("values_heading")} />
|
||||
<Values values={values} />
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Focus Areas */}
|
||||
<AnimatedSection className="mb-20">
|
||||
<SectionHeader label={t("focus_heading")} />
|
||||
<FocusAreas areas={focusAreas} />
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Timeline */}
|
||||
<AnimatedSection>
|
||||
<SectionHeader label={t("journey")} />
|
||||
<Timeline events={timelineEvents as Parameters<typeof Timeline>[0]["events"]} />
|
||||
</AnimatedSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,106 @@
|
||||
export default function ContactPage() {
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Github, Linkedin, Send, Twitter, Instagram, MapPin } from "lucide-react";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { AnimatedSection } from "@/components/shared/AnimatedSection";
|
||||
import { ContactForm } from "@/components/shared/ContactForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "contact" });
|
||||
return { title: t("title"), description: t("subtitle") };
|
||||
}
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: Github, href: "https://github.com/alitaghavi", label: "GitHub" },
|
||||
{ icon: Linkedin, href: "https://linkedin.com/in/alitaghavi", label: "LinkedIn" },
|
||||
{ icon: Twitter, href: "https://twitter.com/alitaghavi", label: "Twitter/X" },
|
||||
{ icon: Send, href: "https://t.me/alitaghavi", label: "Telegram" },
|
||||
{ icon: Instagram, href: "https://instagram.com/alitaghavi", label: "Instagram" },
|
||||
];
|
||||
|
||||
export default async function ContactPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "contact" });
|
||||
const openToItems = t.raw("open_to_items") as string[];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-32">
|
||||
<span className="font-mono text-xs text-accent">// contact</span>
|
||||
<h1 className="mt-4 text-4xl font-bold text-text-primary">تماس</h1>
|
||||
<p className="mt-4 text-text-secondary">در حال آمادهسازی — Phase 3</p>
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
|
||||
<SectionHeader label="contact" />
|
||||
|
||||
<div className="grid gap-12 lg:grid-cols-[1fr_380px]">
|
||||
{/* Form */}
|
||||
<AnimatedSection>
|
||||
<h1 className="mb-2 text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
|
||||
<p className="mb-8 text-text-secondary">{t("subtitle")}</p>
|
||||
<ContactForm />
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Sidebar info */}
|
||||
<AnimatedSection delay={0.1} className="space-y-8">
|
||||
{/* Open to */}
|
||||
<div className="rounded-xl border border-border bg-surface p-6">
|
||||
<div className="mb-4 font-mono text-xs text-accent">// {t("open_to")}</div>
|
||||
<ul className="space-y-2">
|
||||
{openToItems.map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="flex items-center gap-2 font-mono text-sm text-text-secondary">
|
||||
<MapPin size={14} className="text-accent shrink-0" />
|
||||
{t("based_in")}
|
||||
</div>
|
||||
|
||||
{/* Social */}
|
||||
<div>
|
||||
<div className="mb-4 font-mono text-xs text-accent">// social</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{socialLinks.map(({ icon: Icon, href, label }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent hover:bg-accent-muted"
|
||||
>
|
||||
<Icon size={16} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Telegram CTA */}
|
||||
<div className="relative overflow-hidden rounded-xl border border-accent/20 bg-surface p-6">
|
||||
<div className="absolute -right-8 -top-8 h-24 w-24 rounded-full bg-accent/5 blur-2xl" />
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-accent-muted">
|
||||
<Send size={18} className="text-accent" />
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-text-secondary leading-relaxed">
|
||||
{locale === "fa"
|
||||
? "برای یادداشتهای روزانه، ایدهها و آپدیتها، کانال تلگرامم را دنبال کن"
|
||||
: "For daily notes, ideas, and updates, follow my Telegram channel"}
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href="https://t.me/alitaghavi_channel" target="_blank" rel="noopener noreferrer">
|
||||
<Send size={13} />
|
||||
{locale === "fa" ? "عضو کانال" : "Join Channel"}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
48
src/app/[locale]/error.tsx
Normal file
48
src/app/[locale]/error.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { RefreshCw, Home } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AnimatedSection } from "@/components/shared/AnimatedSection";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("errors");
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex min-h-[70vh] flex-col items-center justify-center px-6 py-24 text-center">
|
||||
<AnimatedSection>
|
||||
<div className="mb-6 font-mono text-8xl font-bold text-danger/20 select-none">500</div>
|
||||
<div className="mb-3 font-mono text-sm text-danger">// {t("error.label")}</div>
|
||||
<h1 className="mb-4 text-3xl font-bold text-text-primary md:text-4xl">{t("error.title")}</h1>
|
||||
<p className="mb-8 max-w-md text-text-secondary">{t("error.description")}</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw size={15} />
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">
|
||||
<Home size={15} />
|
||||
{t("error.home")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{error.digest && (
|
||||
<p className="mt-6 font-mono text-xs text-text-secondary/50">digest: {error.digest}</p>
|
||||
)}
|
||||
</AnimatedSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -85,9 +85,15 @@ export default async function LocaleLayout({
|
||||
<link rel="alternate" type="application/rss+xml" title="علی تقوی — نوشتهها" href="/api/rss" />
|
||||
</head>
|
||||
<body className="relative flex min-h-full flex-col bg-background text-text-primary">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-100 focus:rounded-md focus:bg-accent focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-background focus:outline-none"
|
||||
>
|
||||
{dir === "rtl" ? "رفتن به محتوای اصلی" : "Skip to main content"}
|
||||
</a>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Header />
|
||||
<main className="flex-1 relative z-10">{children}</main>
|
||||
<main id="main-content" className="flex-1 relative z-10">{children}</main>
|
||||
<Footer />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
|
||||
36
src/app/[locale]/not-found.tsx
Normal file
36
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { Home, ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AnimatedSection } from "@/components/shared/AnimatedSection";
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations("errors");
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex min-h-[70vh] flex-col items-center justify-center px-6 py-24 text-center">
|
||||
<AnimatedSection>
|
||||
<div className="mb-6 font-mono text-8xl font-bold text-accent/20 select-none">404</div>
|
||||
<div className="mb-3 font-mono text-sm text-accent">// {t("notFound.label")}</div>
|
||||
<h1 className="mb-4 text-3xl font-bold text-text-primary md:text-4xl">{t("notFound.title")}</h1>
|
||||
<p className="mb-8 max-w-md text-text-secondary">{t("notFound.description")}</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/">
|
||||
<Home size={15} />
|
||||
{t("notFound.home")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/writing">
|
||||
<ArrowRight size={15} />
|
||||
{t("notFound.writing")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
src/app/[locale]/resume/page.tsx
Normal file
187
src/app/[locale]/resume/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Download, Calendar, MapPin } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { AnimatedSection } from "@/components/shared/AnimatedSection";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "resume" });
|
||||
return { title: t("title") };
|
||||
}
|
||||
|
||||
const experience = [
|
||||
{
|
||||
role: "مدیرعامل (CEO)",
|
||||
roleEn: "CEO & Founder",
|
||||
company: "NODE-Group",
|
||||
period: "۱۴۰۱",
|
||||
periodEn: "2022",
|
||||
presentKey: true,
|
||||
location: "تهران، ایران",
|
||||
locationEn: "Tehran, Iran",
|
||||
bullets: [
|
||||
"راهاندازی و رهبری شرکت فناوری با محصولات HyperAccount، Khanehban و NODE-AUTH",
|
||||
"طراحی استراتژی محصول، برند و بازاریابی دیجیتال",
|
||||
"مدیریت تیم توسعه و عملیات",
|
||||
],
|
||||
bulletsEn: [
|
||||
"Founded and lead technology company with products HyperAccount, Khanehban, and NODE-AUTH",
|
||||
"Designed product, brand, and digital marketing strategy",
|
||||
"Managed development and operations teams",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const education = [
|
||||
{
|
||||
degree: "کارشناسی ارشد مدیریت کسبوکار (MBA)",
|
||||
degreeEn: "Master of Business Administration (MBA)",
|
||||
school: "دانشگاه",
|
||||
schoolEn: "University",
|
||||
year: "۱۴۰۰",
|
||||
yearEn: "2021",
|
||||
},
|
||||
];
|
||||
|
||||
const skillGroups = [
|
||||
{
|
||||
category: "فناوری",
|
||||
categoryEn: "Technology",
|
||||
skills: ["Next.js", "TypeScript", "React", "Node.js", "Docker", "PostgreSQL", "Sanity CMS"],
|
||||
},
|
||||
{
|
||||
category: "بازاریابی و برند",
|
||||
categoryEn: "Marketing & Brand",
|
||||
skills: ["دیجیتال مارکتینگ", "برندینگ", "SEO", "محتوا مارکتینگ", "استراتژی رسانههای اجتماعی"],
|
||||
},
|
||||
{
|
||||
category: "استراتژی محصول",
|
||||
categoryEn: "Product Strategy",
|
||||
skills: ["Product Management", "UX Research", "A/B Testing", "Roadmapping", "Customer Discovery"],
|
||||
},
|
||||
{
|
||||
category: "ابزارها",
|
||||
categoryEn: "Tools",
|
||||
skills: ["Figma", "Linear", "Notion", "VS Code", "GitHub Actions"],
|
||||
},
|
||||
];
|
||||
|
||||
export default async function ResumePage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "resume" });
|
||||
const isRtl = locale === "fa";
|
||||
|
||||
return (
|
||||
<div className="relative z-10 mx-auto max-w-4xl px-6 py-24 pt-32">
|
||||
{/* Header */}
|
||||
<AnimatedSection>
|
||||
<div className="mb-12 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SectionHeader label="resume" />
|
||||
<h1 className="text-4xl font-bold text-text-primary">
|
||||
{isRtl ? "علی تقوی" : "Ali Taghavi"}
|
||||
</h1>
|
||||
<p className="mt-2 text-text-secondary">
|
||||
{isRtl ? "مدیرعامل NODE-Group · سازنده · استراتژیست" : "CEO @ NODE-Group · Builder · Strategist"}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-text-secondary">
|
||||
<span className="flex items-center gap-1"><MapPin size={13} /> {isRtl ? "تهران، ایران" : "Tehran, Iran"}</span>
|
||||
<span className="font-mono">biztaghavi.com</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="secondary" size="sm" className="shrink-0">
|
||||
<a href="/resume.pdf" download>
|
||||
<Download size={14} />
|
||||
{t("download_pdf")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Experience */}
|
||||
<AnimatedSection className="mb-12">
|
||||
<SectionHeader label={t("experience")} />
|
||||
<div className="space-y-8">
|
||||
{experience.map((job, i) => (
|
||||
<div key={i} className="rounded-xl border border-border bg-surface p-6">
|
||||
<div className="mb-4 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">
|
||||
{isRtl ? job.role : job.roleEn}
|
||||
</h3>
|
||||
<p className="font-mono text-sm text-accent">{job.company}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end shrink-0">
|
||||
<div className="flex items-center gap-1 font-mono text-xs text-text-secondary">
|
||||
<Calendar size={11} />
|
||||
{isRtl ? job.period : job.periodEn} — {t("present")}
|
||||
</div>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
<MapPin size={11} className="inline me-1" />
|
||||
{isRtl ? job.location : job.locationEn}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{(isRtl ? job.bullets : job.bulletsEn).map((b, j) => (
|
||||
<li key={j} className="flex items-start gap-2 text-sm text-text-secondary">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
|
||||
{b}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Education */}
|
||||
<AnimatedSection className="mb-12">
|
||||
<SectionHeader label={t("education")} />
|
||||
<div className="space-y-4">
|
||||
{education.map((edu, i) => (
|
||||
<div key={i} className="rounded-xl border border-border bg-surface p-6">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">
|
||||
{isRtl ? edu.degree : edu.degreeEn}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary">{isRtl ? edu.school : edu.schoolEn}</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-text-secondary shrink-0">
|
||||
{isRtl ? edu.year : edu.yearEn}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Skills */}
|
||||
<AnimatedSection>
|
||||
<SectionHeader label={t("skills")} />
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{skillGroups.map((group, i) => (
|
||||
<div key={i} className="rounded-xl border border-border bg-surface p-5">
|
||||
<h3 className="mb-3 font-mono text-xs text-accent">
|
||||
// {isRtl ? group.category : group.categoryEn}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.skills.map((skill) => (
|
||||
<Badge key={skill} variant="secondary">{skill}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/app/[locale]/shop/page.tsx
Normal file
154
src/app/[locale]/shop/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { AnimatedSection } from "@/components/shared/AnimatedSection";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { sanityFetch } from "@/lib/sanity/client";
|
||||
import { allProductsQuery } from "@/lib/sanity/queries";
|
||||
import { urlFor } from "@/lib/sanity/image";
|
||||
import type { SanityImage } from "@/lib/sanity/types";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
_id: string;
|
||||
title: string;
|
||||
slug: { current: string };
|
||||
description?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
productType?: string;
|
||||
purchaseUrl?: string;
|
||||
featured?: boolean;
|
||||
coverImage?: SanityImage;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "shop" });
|
||||
return { title: t("title"), description: t("subtitle") };
|
||||
}
|
||||
|
||||
// Placeholder products shown when Sanity has no data yet
|
||||
const placeholders: Product[] = [
|
||||
{
|
||||
_id: "p1",
|
||||
title: "دوره بازاریابی دیجیتال برای استارتاپهای ایرانی",
|
||||
slug: { current: "digital-marketing-course" },
|
||||
description: "آموزش جامع استراتژی بازاریابی دیجیتال متناسب با بازار ایران — از صفر تا اجرا",
|
||||
price: 490000,
|
||||
currency: "IRR",
|
||||
productType: "digital",
|
||||
},
|
||||
{
|
||||
_id: "p2",
|
||||
title: "قالب استراتژی برند",
|
||||
slug: { current: "brand-strategy-template" },
|
||||
description: "قالب آماده برای طراحی هویت برند و استراتژی بصری کسبوکار",
|
||||
price: 0,
|
||||
currency: "IRR",
|
||||
productType: "digital",
|
||||
},
|
||||
{
|
||||
_id: "p3",
|
||||
title: "HyperAccount",
|
||||
slug: { current: "hyperaccount" },
|
||||
description: "زیرساخت هویت و حساب کاربری برای محصولات دیجیتال ایرانی",
|
||||
productType: "node-product",
|
||||
purchaseUrl: "https://hyperaccount.ir",
|
||||
},
|
||||
];
|
||||
|
||||
export default async function ShopPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "shop" });
|
||||
const isRtl = locale === "fa";
|
||||
|
||||
const sanityProducts = await sanityFetch<Product[]>(allProductsQuery, { locale });
|
||||
const products = sanityProducts && sanityProducts.length > 0 ? sanityProducts : placeholders;
|
||||
|
||||
function formatPrice(price: number, currency: string) {
|
||||
if (price === 0) return isRtl ? t("free") : "Free";
|
||||
if (currency === "IRR") {
|
||||
return new Intl.NumberFormat("fa-IR").format(price) + " تومان";
|
||||
}
|
||||
return `$${price}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
|
||||
<SectionHeader label="shop" />
|
||||
<AnimatedSection className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
|
||||
<p className="mt-4 max-w-2xl text-text-secondary">{t("subtitle")}</p>
|
||||
</AnimatedSection>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((product, i) => {
|
||||
const imageUrl = product.coverImage?.asset
|
||||
? urlFor(product.coverImage).width(600).height(340).url()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AnimatedSection key={product._id} delay={i * 0.07}>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-accent/20 hover:bg-surface-hover">
|
||||
{/* Image or placeholder */}
|
||||
{imageUrl ? (
|
||||
<div className="relative h-44 overflow-hidden">
|
||||
<Image src={imageUrl} alt={product.title} fill className="object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-44 items-center justify-center bg-surface-hover">
|
||||
<span className="font-mono text-3xl text-accent opacity-20">//</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Badge variant={product.productType === "node-product" ? "default" : "secondary"}>
|
||||
{product.productType === "node-product" ? t("node_product") : t("digital")}
|
||||
</Badge>
|
||||
{product.featured && (
|
||||
<span className="font-mono text-xs text-accent">★</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 font-semibold text-text-primary">{product.title}</h3>
|
||||
|
||||
{product.description && (
|
||||
<p className="mb-4 flex-1 text-sm text-text-secondary leading-relaxed line-clamp-3">
|
||||
{product.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-3">
|
||||
{product.price !== undefined && (
|
||||
<span className="font-mono text-sm font-semibold text-accent">
|
||||
{formatPrice(product.price, product.currency ?? "IRR")}
|
||||
</span>
|
||||
)}
|
||||
{product.purchaseUrl ? (
|
||||
<Button asChild size="sm" className="ms-auto">
|
||||
<a href={product.purchaseUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink size={13} />
|
||||
{t("buy")}
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="ms-auto">{t("buy")}</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/app/[locale]/uses/page.tsx
Normal file
104
src/app/[locale]/uses/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { AnimatedSection } from "@/components/shared/AnimatedSection";
|
||||
import { sanityFetch } from "@/lib/sanity/client";
|
||||
import { allUsesItemsQuery } from "@/lib/sanity/queries";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
type UsesCategory = "development" | "design" | "marketing" | "productivity" | "hardware";
|
||||
|
||||
interface UsesItem {
|
||||
_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
category: UsesCategory;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "uses" });
|
||||
return { title: t("title"), description: t("subtitle") };
|
||||
}
|
||||
|
||||
// Static fallback — shown until Sanity has data
|
||||
const fallbackItems: UsesItem[] = [
|
||||
{ _id: "1", title: "VS Code", description: "اصلیترین ابزار کدنویسیام", category: "development", url: "https://code.visualstudio.com" },
|
||||
{ _id: "2", title: "Next.js", description: "فریمورک React برای وب اپلیکیشنها", category: "development", url: "https://nextjs.org" },
|
||||
{ _id: "3", title: "Sanity", description: "هدلس CMS برای مدیریت محتوا", category: "development", url: "https://sanity.io" },
|
||||
{ _id: "4", title: "Docker", description: "کانتینرایزیشن برای دپلوی", category: "development" },
|
||||
{ _id: "5", title: "Figma", description: "طراحی UI/UX و هویت بصری", category: "design", url: "https://figma.com" },
|
||||
{ _id: "6", title: "Linear", description: "مدیریت پروژه و تسکها", category: "productivity", url: "https://linear.app" },
|
||||
{ _id: "7", title: "Notion", description: "مستندسازی و یادداشتبرداری", category: "productivity", url: "https://notion.so" },
|
||||
{ _id: "8", title: "macOS", description: "سیستمعامل اصلی", category: "hardware" },
|
||||
{ _id: "9", title: "Google Analytics + Umami", description: "آنالیتیکس سایت — هر دو برای مقایسه", category: "marketing" },
|
||||
];
|
||||
|
||||
const categoryOrder: UsesCategory[] = ["development", "design", "marketing", "productivity", "hardware"];
|
||||
|
||||
export default async function UsesPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "uses" });
|
||||
const tCat = await getTranslations({ locale, namespace: "uses.categories" });
|
||||
|
||||
const sanityItems = await sanityFetch<UsesItem[]>(allUsesItemsQuery);
|
||||
const items = sanityItems && sanityItems.length > 0 ? sanityItems : fallbackItems;
|
||||
|
||||
const grouped = categoryOrder.reduce<Record<string, UsesItem[]>>((acc, cat) => {
|
||||
const catItems = items.filter((i) => i.category === cat);
|
||||
if (catItems.length > 0) acc[cat] = catItems;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="relative z-10 mx-auto max-w-4xl px-6 py-24 pt-32">
|
||||
<SectionHeader label="uses" />
|
||||
<AnimatedSection className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
|
||||
<p className="mt-4 text-text-secondary">{t("subtitle")}</p>
|
||||
</AnimatedSection>
|
||||
|
||||
<div className="space-y-14">
|
||||
{Object.entries(grouped).map(([cat, catItems], groupIdx) => (
|
||||
<AnimatedSection key={cat} delay={groupIdx * 0.05}>
|
||||
<h2 className="mb-6 flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-accent">// {tCat(cat as UsesCategory)}</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{catItems.map((item) => (
|
||||
<div
|
||||
key={item._id}
|
||||
className="group flex items-start justify-between gap-3 rounded-lg border border-border bg-surface p-4 transition-all hover:border-accent/20"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-text-primary">{item.title}</div>
|
||||
{item.description && (
|
||||
<p className="mt-0.5 text-sm text-text-secondary">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-0.5 shrink-0 text-text-secondary opacity-0 transition-opacity group-hover:opacity-100 hover:text-accent"
|
||||
aria-label={`Visit ${item.title}`}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/app/api/contact/route.ts
Normal file
36
src/app/api/contact/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Resend } from "resend";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { name, email, subject, message } = await req.json();
|
||||
|
||||
if (!name || !email || !message) {
|
||||
return Response.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
await resend.emails.send({
|
||||
from: "biztaghavi.com <noreply@biztaghavi.com>",
|
||||
to: [process.env.CONTACT_EMAIL ?? "ali@biztaghavi.com"],
|
||||
replyTo: email,
|
||||
subject: subject ? `[biztaghavi.com] ${subject}` : `[biztaghavi.com] پیام از ${name}`,
|
||||
text: `نام: ${name}\nایمیل: ${email}\n\n${message}`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px;">
|
||||
<p><strong>نام:</strong> ${name}</p>
|
||||
<p><strong>ایمیل:</strong> ${email}</p>
|
||||
${subject ? `<p><strong>موضوع:</strong> ${subject}</p>` : ""}
|
||||
<hr />
|
||||
<p style="white-space: pre-wrap;">${message}</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[contact]", err);
|
||||
return Response.json({ error: "Failed to send" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -10,10 +11,26 @@ export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://biztaghavi.com"),
|
||||
};
|
||||
|
||||
const umamiSrc = process.env.NEXT_PUBLIC_UMAMI_URL;
|
||||
const umamiId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return children;
|
||||
return (
|
||||
<>
|
||||
{umamiSrc && umamiId && (
|
||||
<Script
|
||||
async
|
||||
defer
|
||||
src={umamiSrc}
|
||||
data-website-id={umamiId}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
38
src/components/about/FocusAreas.tsx
Normal file
38
src/components/about/FocusAreas.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
"فناوری و توسعه نرمافزار": "⚡",
|
||||
"بازاریابی دیجیتال": "📢",
|
||||
"برندینگ و هویت بصری": "🎨",
|
||||
"استراتژی محصول": "🧭",
|
||||
"محتوا و تولید محتوا": "✍️",
|
||||
"متنباز": "🔓",
|
||||
"Technology & Software Development": "⚡",
|
||||
"Digital Marketing": "📢",
|
||||
"Branding & Visual Identity": "🎨",
|
||||
"Product Strategy": "🧭",
|
||||
"Content & Content Creation": "✍️",
|
||||
"Open Source": "🔓",
|
||||
};
|
||||
|
||||
export function FocusAreas({ areas }: { areas: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{areas.map((area, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="flex items-center gap-2 rounded-full border border-border bg-surface px-4 py-2 text-sm text-text-secondary hover:border-accent/30 hover:text-text-primary transition-colors"
|
||||
>
|
||||
<span>{icons[area] ?? "●"}</span>
|
||||
{area}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/about/Timeline.tsx
Normal file
74
src/components/about/Timeline.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TimelineEvent {
|
||||
title: string;
|
||||
date: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
category?: "career" | "product" | "personal";
|
||||
}
|
||||
|
||||
const categoryColor: Record<string, string> = {
|
||||
career: "text-accent border-accent/40 bg-accent-muted",
|
||||
product: "text-blue-400 border-blue-400/40 bg-blue-400/10",
|
||||
personal: "text-purple-400 border-purple-400/40 bg-purple-400/10",
|
||||
};
|
||||
|
||||
// Static fallback timeline — will be overridden by Sanity data when available
|
||||
const fallbackEvents: TimelineEvent[] = [
|
||||
{ title: "تأسیس NODE-Group", date: "۱۴۰۱", icon: "🏢", category: "career", description: "راهاندازی شرکت فناوری NODE-Group برای ساختن محصولات نوآورانه" },
|
||||
{ title: "راهاندازی HyperAccount", date: "۱۴۰۲", icon: "⚡", category: "product", description: "عرضه زیرساخت هویت و حساب کاربری برای وب نسل بعدی" },
|
||||
{ title: "ساخت Khanehban", date: "۱۴۰۲", icon: "🏠", category: "product", description: "مدیریت هوشمند املاک برای موجران و مستأجران ایرانی" },
|
||||
{ title: "NODE-AUTH (Dezhban)", date: "۱۴۰۳", icon: "🔐", category: "product", description: "لایه احراز هویت و مجوزدهی برای محصولات ایرانی" },
|
||||
{ title: "MBA", date: "۱۴۰۰", icon: "🎓", category: "personal", description: "فارغالتحصیلی از دوره MBA" },
|
||||
];
|
||||
|
||||
interface TimelineProps {
|
||||
events?: TimelineEvent[];
|
||||
}
|
||||
|
||||
export function Timeline({ events }: TimelineProps) {
|
||||
const items = events && events.length > 0 ? events : fallbackEvents;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute start-5 top-0 bottom-0 w-px bg-border" />
|
||||
|
||||
<div className="space-y-8">
|
||||
{items.map((event, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-40px" }}
|
||||
transition={{ duration: 0.4, delay: i * 0.05 }}
|
||||
className="relative flex gap-6 ps-14"
|
||||
>
|
||||
{/* Icon dot */}
|
||||
<div className="absolute start-0 flex h-10 w-10 items-center justify-center rounded-full border border-border bg-surface text-lg">
|
||||
{event.icon ?? "●"}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pb-2">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold text-text-primary">{event.title}</h3>
|
||||
{event.category && (
|
||||
<span className={cn("rounded-full border px-2 py-0.5 font-mono text-xs", categoryColor[event.category])}>
|
||||
{event.date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{event.description && (
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{event.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/about/Values.tsx
Normal file
28
src/components/about/Values.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface Value {
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export function Values({ values }: { values: Value[] }) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{values.map((v, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-40px" }}
|
||||
transition={{ duration: 0.4, delay: i * 0.07 }}
|
||||
className="rounded-lg border border-border bg-surface p-5 hover:border-accent/20 transition-colors"
|
||||
>
|
||||
<div className="mb-1 font-semibold text-text-primary">{v.title}</div>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{v.desc}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,9 @@ const navLinks = [
|
||||
{ href: "/writing", labelKey: "writing" },
|
||||
{ href: "/work", labelKey: "work" },
|
||||
{ href: "/about", labelKey: "about" },
|
||||
{ href: "/resume", labelKey: "resume" },
|
||||
{ href: "/shop", labelKey: "shop" },
|
||||
{ href: "/uses", labelKey: "uses" },
|
||||
{ href: "/contact", labelKey: "contact" },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ import { Link } from "@/lib/i18n/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
|
||||
const navKeys = ["home", "writing", "work", "about", "contact"] as const;
|
||||
const navKeys = ["home", "writing", "work", "about", "shop", "uses", "resume", "contact"] as const;
|
||||
const navHrefs: Record<string, string> = {
|
||||
home: "/",
|
||||
writing: "/writing",
|
||||
work: "/work",
|
||||
about: "/about",
|
||||
shop: "/shop",
|
||||
uses: "/uses",
|
||||
resume: "/resume",
|
||||
contact: "/contact",
|
||||
};
|
||||
|
||||
|
||||
90
src/components/shared/ContactForm.tsx
Normal file
90
src/components/shared/ContactForm.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Send, Check, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Status = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function ContactForm() {
|
||||
const t = useTranslations("contact");
|
||||
const locale = useLocale();
|
||||
const isRtl = locale === "fa";
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [form, setForm] = useState({ name: "", email: "", subject: "", message: "" });
|
||||
|
||||
function set(field: keyof typeof form) {
|
||||
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setStatus("loading");
|
||||
try {
|
||||
const res = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
setStatus(res.ok ? "success" : "error");
|
||||
if (res.ok) setForm({ name: "", email: "", subject: "", message: "" });
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface px-4 py-3 text-sm text-text-primary placeholder:text-text-secondary focus:border-accent/50 focus:outline-none transition-colors";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block font-mono text-xs text-text-secondary">{t("name")}</label>
|
||||
<input required value={form.name} onChange={set("name")} className={inputClass} placeholder={isRtl ? "علی تقوی" : "Your name"} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block font-mono text-xs text-text-secondary">{t("email")}</label>
|
||||
<input required type="email" value={form.email} onChange={set("email")} className={inputClass} placeholder="hello@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block font-mono text-xs text-text-secondary">{t("subject")}</label>
|
||||
<input value={form.subject} onChange={set("subject")} className={inputClass} placeholder={isRtl ? "موضوع پیام (اختیاری)" : "Subject (optional)"} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block font-mono text-xs text-text-secondary">{t("message")}</label>
|
||||
<textarea
|
||||
required
|
||||
rows={6}
|
||||
value={form.message}
|
||||
onChange={set("message")}
|
||||
className={`${inputClass} resize-none`}
|
||||
placeholder={isRtl ? "پیامت را اینجا بنویس..." : "Your message..."}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === "success" && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">
|
||||
<Check size={15} />
|
||||
{t("success")}
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
|
||||
<AlertCircle size={15} />
|
||||
{t("error")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" size="lg" disabled={status === "loading"} className="w-full sm:w-auto">
|
||||
<Send size={15} />
|
||||
{status === "loading" ? t("sending") : t("send")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +89,23 @@ export const siteSettingsQuery = groq`
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Products ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const allProductsQuery = groq`
|
||||
*[_type == "product" && locale == $locale] | order(_createdAt desc) {
|
||||
_id, title, slug, description, price, currency, productType, purchaseUrl, featured,
|
||||
"coverImage": coverImage { asset->, alt }
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Uses items ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const allUsesItemsQuery = groq`
|
||||
*[_type == "usesItem"] | order(category asc, title asc) {
|
||||
_id, title, description, category, url, "image": image { asset-> }
|
||||
}
|
||||
`;
|
||||
|
||||
// ── RSS (all fa posts) ─────────────────────────────────────────────────────
|
||||
|
||||
export const rssPostsQuery = groq`
|
||||
|
||||
@@ -79,5 +79,88 @@
|
||||
"outcome": "Outcome",
|
||||
"tech_stack": "Tech Stack",
|
||||
"tools": "Tools Used"
|
||||
},
|
||||
"about": {
|
||||
"title": "About Ali Taghavi",
|
||||
"subtitle": "A builder from Tehran operating at the intersection of technology, branding, and product",
|
||||
"bio_heading": "Who I Am",
|
||||
"bio": "I'm Ali Taghavi — CEO of NODE-Group and a builder who creates from Tehran for the world. For over a decade I've been building products, brands, and systems — from software to marketing strategy and visual identity.\n\nMy work sits at the intersection of technology, digital marketing, and branding. I believe the best products come from combining systematic thinking with deep human understanding.",
|
||||
"working_on": "What I'm Working On",
|
||||
"journey": "Journey",
|
||||
"values_heading": "What I Believe In",
|
||||
"focus_heading": "Areas of Focus",
|
||||
"values": [
|
||||
{ "title": "Transparency", "desc": "In work and communication, I speak clearly and honestly" },
|
||||
{ "title": "Systems Thinking", "desc": "I see problems as systems, not isolated events" },
|
||||
{ "title": "Building for Iran", "desc": "I build products truly designed for Persian-speaking users" },
|
||||
{ "title": "Long-term Quality", "desc": "I prefer to build less but build it right and lasting" }
|
||||
],
|
||||
"focus_areas": [
|
||||
"Technology & Software Development",
|
||||
"Digital Marketing",
|
||||
"Branding & Visual Identity",
|
||||
"Product Strategy",
|
||||
"Content & Content Creation",
|
||||
"Open Source"
|
||||
]
|
||||
},
|
||||
"resume": {
|
||||
"title": "Resume",
|
||||
"download_pdf": "Download PDF",
|
||||
"experience": "Experience",
|
||||
"education": "Education",
|
||||
"skills": "Skills",
|
||||
"certifications": "Certifications",
|
||||
"present": "Present"
|
||||
},
|
||||
"shop": {
|
||||
"title": "Shop",
|
||||
"subtitle": "Courses, templates, and resources I've built",
|
||||
"buy": "Buy",
|
||||
"free": "Free",
|
||||
"digital": "Digital",
|
||||
"node_product": "NODE Product"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact",
|
||||
"subtitle": "For collaboration, consulting, or any question",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"subject": "Subject",
|
||||
"message": "Message",
|
||||
"send": "Send Message",
|
||||
"sending": "Sending...",
|
||||
"success": "Message received! I'll get back to you soon.",
|
||||
"error": "Something went wrong. Please try again.",
|
||||
"open_to": "Open to",
|
||||
"open_to_items": ["Consulting", "Project Collaboration", "Speaking"],
|
||||
"based_in": "Based in Tehran, Iran"
|
||||
},
|
||||
"uses": {
|
||||
"title": "Uses",
|
||||
"subtitle": "Tools, software, and hardware I use daily",
|
||||
"categories": {
|
||||
"development": "Development",
|
||||
"design": "Design",
|
||||
"marketing": "Marketing",
|
||||
"productivity": "Productivity",
|
||||
"hardware": "Hardware"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"notFound": {
|
||||
"label": "not-found",
|
||||
"title": "Page not found",
|
||||
"description": "The page you're looking for doesn't exist or has been moved.",
|
||||
"home": "Back to home",
|
||||
"writing": "Browse writing"
|
||||
},
|
||||
"error": {
|
||||
"label": "server-error",
|
||||
"title": "Something went wrong",
|
||||
"description": "An error occurred on the server. Please try again.",
|
||||
"retry": "Try again",
|
||||
"home": "Back to home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,5 +79,88 @@
|
||||
"outcome": "نتیجه",
|
||||
"tech_stack": "تکنولوژیها",
|
||||
"tools": "ابزارها"
|
||||
},
|
||||
"about": {
|
||||
"title": "درباره علی تقوی",
|
||||
"subtitle": "سازندهای از تهران که در تقاطع فناوری، برندینگ و محصول کار میکند",
|
||||
"bio_heading": "چه کسی هستم",
|
||||
"bio": "علی تقوی هستم — مدیرعامل NODE-Group و یک سازنده که از تهران برای دنیا میسازد. بیش از یک دهه است که محصول، برند و سیستم میسازم؛ از نرمافزار گرفته تا استراتژی بازاریابی و هویت بصری.\n\nکارم در تقاطع فناوری، بازاریابی دیجیتال و برندینگ قرار دارد. باور دارم که بهترین محصولات از ترکیب تفکر سیستماتیک با درک عمیق انسانی به وجود میآیند.",
|
||||
"working_on": "الان روی چه کار میکنم",
|
||||
"journey": "مسیر",
|
||||
"values_heading": "چه چیزهایی برایم مهم است",
|
||||
"focus_heading": "حوزههای تمرکز",
|
||||
"values": [
|
||||
{ "title": "شفافیت", "desc": "در کار و ارتباط، صادقانه و روشن حرف میزنم" },
|
||||
{ "title": "تفکر سیستمی", "desc": "مسائل را در قالب سیستمها میبینم، نه رویدادهای مجزا" },
|
||||
{ "title": "ساختن برای ایران", "desc": "محصولاتی میسازم که واقعاً برای کاربر فارسیزبان طراحی شدهاند" },
|
||||
{ "title": "کیفیت بلندمدت", "desc": "ترجیح میدهم کمتر بسازم اما درستتر و ماندگارتر" }
|
||||
],
|
||||
"focus_areas": [
|
||||
"فناوری و توسعه نرمافزار",
|
||||
"بازاریابی دیجیتال",
|
||||
"برندینگ و هویت بصری",
|
||||
"استراتژی محصول",
|
||||
"محتوا و تولید محتوا",
|
||||
"متنباز"
|
||||
]
|
||||
},
|
||||
"resume": {
|
||||
"title": "رزومه",
|
||||
"download_pdf": "دانلود PDF",
|
||||
"experience": "تجربه کاری",
|
||||
"education": "تحصیلات",
|
||||
"skills": "مهارتها",
|
||||
"certifications": "گواهینامهها",
|
||||
"present": "اکنون"
|
||||
},
|
||||
"shop": {
|
||||
"title": "فروشگاه",
|
||||
"subtitle": "دورهها، قالبها و منابعی که ساختهام",
|
||||
"buy": "خرید",
|
||||
"free": "رایگان",
|
||||
"digital": "دیجیتال",
|
||||
"node_product": "محصول NODE"
|
||||
},
|
||||
"contact": {
|
||||
"title": "تماس",
|
||||
"subtitle": "برای همکاری، مشاوره یا هر سوالی پیام بده",
|
||||
"name": "نام",
|
||||
"email": "ایمیل",
|
||||
"subject": "موضوع",
|
||||
"message": "پیام",
|
||||
"send": "ارسال پیام",
|
||||
"sending": "در حال ارسال...",
|
||||
"success": "پیامت دریافت شد! بهزودی جواب میدم.",
|
||||
"error": "مشکلی پیش آمد. دوباره تلاش کن.",
|
||||
"open_to": "آماده همکاری در",
|
||||
"open_to_items": ["مشاوره", "همکاری در پروژه", "سخنرانی"],
|
||||
"based_in": "مستقر در تهران، ایران"
|
||||
},
|
||||
"uses": {
|
||||
"title": "ابزارها",
|
||||
"subtitle": "ابزارها، نرمافزارها و سختافزارهایی که ازشان استفاده میکنم",
|
||||
"categories": {
|
||||
"development": "توسعه",
|
||||
"design": "طراحی",
|
||||
"marketing": "بازاریابی",
|
||||
"productivity": "بهرهوری",
|
||||
"hardware": "سختافزار"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"notFound": {
|
||||
"label": "not-found",
|
||||
"title": "صفحه پیدا نشد",
|
||||
"description": "صفحهای که دنبالش میگردی وجود ندارد یا منتقل شده.",
|
||||
"home": "برگشت به خانه",
|
||||
"writing": "مشاهده نوشتهها"
|
||||
},
|
||||
"error": {
|
||||
"label": "server-error",
|
||||
"title": "مشکلی پیش آمد",
|
||||
"description": "خطایی در سرور رخ داد. لطفاً دوباره تلاش کن.",
|
||||
"retry": "تلاش دوباره",
|
||||
"home": "برگشت به خانه"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user