diff --git a/.env.example b/.env.example index 1f98b6c..33f62eb 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,7 @@ BASESCAN_API_KEY="your_basescan_api_key" # Mapbox (for scan location maps) NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN="your_mapbox_access_token" + +# Ghost Blog API (for blog posts) +NEXT_PUBLIC_GHOST_URL="https://blog.example.com" +NEXT_PUBLIC_TOKEN="your_ghost_content_api_key" diff --git a/CLAUDE.md b/CLAUDE.md index 2f90b32..fac8874 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,16 @@ Server actions are organized in `src/lib/actions/`: **Public Routes:** - `/` - Public landing page +- `/about` - About Etags, mission, vision, team +- `/features` - Comprehensive features showcase +- `/pricing` - Pricing plans and FAQs +- `/showcase` - Success stories and testimonials +- `/careers` - Job openings and company culture +- `/contact` - Contact form and information +- `/blog` - Blog articles from Ghost CMS +- `/privacy` - Privacy policy +- `/terms` - Terms and conditions +- `/security` - Security practices and policies - `/login` - Login page (redirects to /manage if authenticated) - `/register` - User registration page - `/scan` - QR code scanner for tag verification @@ -249,3 +259,4 @@ Copy `.env.example` to `.env` and configure: - `KOLOSAL_API_KEY` - Kolosal AI for fraud detection - `BASESCAN_API_KEY` - BaseScan API for explorer features - `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` - Mapbox token for scan location maps +- `NEXT_PUBLIC_TOKEN` - Ghost CMS Content API key for blog posts diff --git a/README.md b/README.md index 253b112..4208c63 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ | Blockchain | ethers.js, ERC721 NFT (Base Sepolia) | | Storage | Cloudflare R2 | | AI | Kolosal AI (Fraud Detection), Gemini AI (NFT Art) | -| Styling | Tailwind CSS v4, shadcn/ui | +| CMS | Ghost CMS (Blog) | +| Styling | Tailwind CSS v4, shadcn/ui, Framer Motion | | Testing | Vitest | ## Features @@ -53,6 +54,14 @@ - **Auto Product Detection** - System detects owned products from NFT - **Brand/Admin Routing** - Tickets route to brand, fallback to admin +### Public Pages + +- **Landing & Marketing** - About, Features, Pricing, Showcase pages +- **Company** - Careers, Contact pages with form integration +- **Legal** - Privacy Policy, Terms & Conditions, Security documentation +- **Blog** - Ghost CMS integration with pagination support +- **Resources** - FAQ, API Documentation (Swagger) + ## Quick Start ```bash @@ -89,20 +98,49 @@ NFT_CONTRACT_ADDRESS, NEXT_PUBLIC_NFT_CONTRACT_ADDRESS, GEMINI_API_KEY # Optional - AI KOLOSAL_API_KEY + +# Optional - Blog +NEXT_PUBLIC_TOKEN="ghost_content_api_key" # Ghost CMS Content API Key ``` ## Routes -| Route | Description | -| ---------------- | -------------------- | -| `/` | Landing page | -| `/login` | Authentication | -| `/scan` | QR scanner | -| `/verify/[code]` | Tag verification | -| `/support` | Web3 support tickets | -| `/explorer` | Blockchain explorer | -| `/manage/*` | Admin dashboard | -| `/docs` | Swagger API docs | +### Public Routes + +| Route | Description | +| ---------------- | ------------------------- | +| `/` | Landing page | +| `/about` | About company & team | +| `/features` | Features showcase | +| `/pricing` | Pricing plans | +| `/showcase` | Success stories | +| `/careers` | Job openings | +| `/contact` | Contact form | +| `/blog` | Blog articles (Ghost CMS) | +| `/privacy` | Privacy policy | +| `/terms` | Terms & conditions | +| `/security` | Security practices | +| `/faqs` | FAQ page | +| `/login` | Authentication | +| `/register` | User registration | +| `/scan` | QR scanner | +| `/verify/[code]` | Tag verification | +| `/support` | Web3 support tickets | +| `/explorer` | Blockchain explorer | +| `/docs` | Swagger API docs | + +### Admin Routes + +| Route | Description | +| ------------------ | ----------------------- | +| `/manage` | Dashboard home | +| `/manage/brands` | Brand management | +| `/manage/products` | Product CRUD | +| `/manage/tags` | Tag management | +| `/manage/nfts` | NFT monitoring | +| `/manage/tickets` | Support tickets | +| `/manage/users` | User management (admin) | +| `/manage/profile` | Profile settings | ## Scripts @@ -129,17 +167,21 @@ docker run -p 3000:3000 -e DATABASE_URL="..." -e AUTH_SECRET="..." etags ``` src/ -├── app/ # Next.js App Router -│ ├── api/ # API Routes -│ ├── manage/ # Admin Dashboard -│ └── support/ # Web3 Support +├── app/ # Next.js App Router +│ ├── api/ # API Routes +│ ├── manage/ # Admin Dashboard +│ ├── about/ # Public pages (about, features, pricing, etc.) +│ ├── blog/ # Blog with Ghost CMS +│ └── support/ # Web3 Support ├── lib/ -│ ├── actions/ # Server Actions -│ └── *.ts # Utilities (db, auth, r2, blockchain) +│ ├── actions/ # Server Actions +│ └── *.ts # Utilities (db, auth, r2, blockchain) ├── components/ -│ ├── ui/ # shadcn/ui components -│ └── landing/ # Landing page components -└── tests/ # Test setup & mocks +│ ├── ui/ # shadcn/ui components +│ ├── landing/ # Landing page components +│ ├── blog/ # Blog components (Grid, Pagination) +│ └── faq/ # FAQ components +└── tests/ # Test setup & mocks ``` ## NFT Collectible Flow @@ -159,9 +201,14 @@ src/ | Phase 3 | 🔜 | Distribution Tracking, Supply Chain | | Phase 4 | 🔜 | Blockchain Warranty | | Phase 5 | ✅ | Web3 Support Tickets | +| Phase 6 | ✅ | Public Pages & Blog (Ghost CMS) | See [ROADMAP.md](./ROADMAP.md) for details. +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidelines. + ## License MIT diff --git a/next.config.ts b/next.config.ts index 7f742f4..c3d17b6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -23,6 +23,21 @@ const nextConfig: NextConfig = { protocol: 'https', hostname: '*.cloudflarestorage.com', }, + { + // Ghost CMS blog images + protocol: 'https', + hostname: 'blog.javapixa.com', + }, + { + // Unsplash images (used by Ghost) + protocol: 'https', + hostname: 'images.unsplash.com', + }, + { + // Ghost CDN + protocol: 'https', + hostname: 'static.ghost.org', + }, ], }, }; diff --git a/package-lock.json b/package-lock.json index f87b5d2..5baee4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@aws-sdk/s3-request-presigner": "^3.705.0", "@fingerprintjs/fingerprintjs": "^5.0.1", "@google/genai": "^1.30.0", - "@gsap/react": "^2.1.2", "@prisma/client": "^6.1.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -24,12 +23,12 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@types/mapbox-gl": "^3.4.1", + "axios": "^1.13.2", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "ethers": "^6.13.4", "framer-motion": "^12.23.25", - "gsap": "^3.13.0", "html5-qrcode": "^2.3.8", "lucide-react": "^0.555.0", "mapbox-gl": "^3.16.0", @@ -2454,16 +2453,6 @@ } } }, - "node_modules/@gsap/react": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@gsap/react/-/react-2.1.2.tgz", - "integrity": "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==", - "license": "SEE LICENSE AT https://gsap.com/standard-license", - "peerDependencies": { - "gsap": "^3.12.5", - "react": ">=17" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -11659,12 +11648,6 @@ "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", "license": "ISC" }, - "node_modules/gsap": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz", - "integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license." - }, "node_modules/gtoken": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", diff --git a/package.json b/package.json index aa0d39c..c005694 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@types/mapbox-gl": "^3.4.1", + "axios": "^1.13.2", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..7704e00 --- /dev/null +++ b/src/app/about/page.tsx @@ -0,0 +1,49 @@ +import { Metadata } from 'next'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { + AboutHero, + MissionVision, + ValuesSection, + TechnologyStack, + TeamSection, + StatsSection, +} from '@/components/about'; + +export const metadata: Metadata = { + title: 'About Us - Etags', + description: + 'Learn about Etags, our mission to secure product authenticity with blockchain technology, and meet the team behind the platform.', + openGraph: { + title: 'About Us - Etags', + description: + 'Platform verifikasi produk berbasis blockchain yang mengamankan rantai pasokan.', + }, +}; + +export default function AboutPage() { + return ( +
+ {/* Background Effects */} +
+
+
+
+ + + +
+
+ + + + + + +
+
+ +
+
+ ); +} diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts new file mode 100644 index 0000000..cb8dc62 --- /dev/null +++ b/src/app/api/contact/route.ts @@ -0,0 +1,164 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + validateContactForm, + sanitizeContactForm, + ContactFormData, +} from '@/lib/validations/contact'; +import { checkRateLimit } from '@/lib/rate-limit'; + +/** + * Rate limiter configuration for contact form submissions + * Allows 3 requests per 15 minutes per IP + */ +const RATE_LIMIT_CONFIG = { + maxRequests: 3, // 3 requests + windowMs: 15 * 60 * 1000, // 15 minutes +}; + +/** + * POST /api/contact + * + * Handles contact form submissions with comprehensive validation, + * rate limiting, and error handling. + * + * @param request - Next.js request object + * @returns JSON response with success/error status + */ +export async function POST(request: NextRequest) { + try { + // Rate limiting + const identifier = + request.headers.get('x-forwarded-for') || + request.headers.get('x-real-ip') || + 'anonymous'; + const rateLimitResult = checkRateLimit(identifier, RATE_LIMIT_CONFIG); + + if (!rateLimitResult.success) { + return NextResponse.json( + { + success: false, + error: + 'Terlalu banyak permintaan. Silakan coba lagi dalam beberapa menit.', + code: 'RATE_LIMIT_EXCEEDED', + }, + { + status: 429, + headers: { + 'Retry-After': rateLimitResult.retryAfter!.toString(), + 'X-RateLimit-Limit': RATE_LIMIT_CONFIG.maxRequests.toString(), + 'X-RateLimit-Remaining': rateLimitResult.remaining.toString(), + 'X-RateLimit-Reset': rateLimitResult.resetTime.toString(), + }, + } + ); + } + + // Parse request body + let body: ContactFormData; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { + success: false, + error: 'Format request tidak valid', + code: 'INVALID_JSON', + }, + { status: 400 } + ); + } + + // Validate required fields exist + if (!body || typeof body !== 'object') { + return NextResponse.json( + { + success: false, + error: 'Data form tidak lengkap', + code: 'MISSING_FIELDS', + }, + { status: 400 } + ); + } + + // Sanitize input + const sanitizedData = sanitizeContactForm(body); + + // Validate form data + const validation = validateContactForm(sanitizedData); + if (!validation.isValid) { + return NextResponse.json( + { + success: false, + error: 'Validasi form gagal', + code: 'VALIDATION_ERROR', + errors: validation.errors, + }, + { status: 400 } + ); + } + + // TODO: Implement actual email sending or database storage + // Examples: + // 1. Send email via SendGrid, Resend, or similar service + // 2. Store in database for follow-up + // 3. Send to Slack/Discord webhook for notifications + + // Simulate processing delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Log successful submission (without PII) in development + if (process.env.NODE_ENV === 'development') { + console.log('Contact form submission processed successfully'); + } + + // Return success response + return NextResponse.json( + { + success: true, + message: + 'Pesan Anda telah diterima. Kami akan menghubungi Anda segera.', + }, + { status: 200 } + ); + } catch (error) { + // Log error without exposing sensitive details + if (process.env.NODE_ENV === 'development') { + console.error( + 'Contact form API error:', + error instanceof Error ? error.message : 'Unknown error' + ); + } + + // Generic error response for security + return NextResponse.json( + { + success: false, + error: + 'Terjadi kesalahan saat memproses permintaan Anda. Silakan coba lagi nanti.', + code: 'INTERNAL_ERROR', + }, + { status: 500 } + ); + } +} + +/** + * GET /api/contact + * + * Returns API information and validation rules + */ +export async function GET() { + return NextResponse.json({ + endpoint: '/api/contact', + method: 'POST', + description: 'Submit contact form', + rateLimit: '3 requests per 15 minutes', + validation: { + name: 'Required, 2-100 characters, letters only', + email: 'Required, valid email format', + company: 'Optional, 2-100 characters', + subject: 'Required, 5-200 characters', + message: 'Required, 20-5000 characters', + }, + }); +} diff --git a/src/app/api/verify/route.ts b/src/app/api/verify/route.ts index 3e1a7c5..9e4e22b 100644 --- a/src/app/api/verify/route.ts +++ b/src/app/api/verify/route.ts @@ -229,22 +229,41 @@ export async function GET(request: NextRequest) { }); // Calculate scan statistics - const totalScans = tag.scans.length; - const uniqueFingerprints = new Set(tag.scans.map((s) => s.fingerprint_id)); + type ScanRow = { + fingerprint_id: string | null; + location_name: string | null; + created_at: Date; + scan_number: number; + is_first_hand: 0 | 1 | null; + source_info?: string | null; + }; + + const scans = tag.scans as unknown as ScanRow[]; + + const totalScans = scans.length; + // Count unique non-empty fingerprint IDs + const uniqueFingerprints = new Set( + scans + .map((s) => s.fingerprint_id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + ); const uniqueScanners = uniqueFingerprints.size; - const scanLocations = [ - ...new Set( - tag.scans - .filter((s) => s.location_name) - .map((s) => s.location_name as string) - ), - ]; + const scanLocations: string[] = Array.from( + new Set( + scans + .map((s) => s.location_name) + .filter( + (name): name is string => + typeof name === 'string' && name.length > 0 + ) + ) + ); - const firstScan = tag.scans[tag.scans.length - 1]; - const lastScan = tag.scans[0]; + const firstScan = scans[scans.length - 1]; + const lastScan = scans[0]; // Build scan history - const scanHistory = tag.scans.map((s) => ({ + const scanHistory = scans.map((s) => ({ scanNumber: s.scan_number, createdAt: s.created_at.toISOString(), locationName: s.location_name || undefined, @@ -295,10 +314,15 @@ export async function GET(request: NextRequest) { // Check multiple locations in short time let multipleLocationsInShortTime = false; - if (tag.scans.length >= 2) { - const recentScans = tag.scans.slice(0, 5); + if (scans.length >= 2) { + const recentScans = scans.slice(0, 5); const uniqueRecentLocations = new Set( - recentScans.filter((s) => s.location_name).map((s) => s.location_name) + recentScans + .map((s) => s.location_name) + .filter( + (name): name is string => + typeof name === 'string' && name.length > 0 + ) ); if (uniqueRecentLocations.size >= 3) { // Check if within 24 hours diff --git a/src/app/blog/loading.tsx b/src/app/blog/loading.tsx new file mode 100644 index 0000000..1b7750d --- /dev/null +++ b/src/app/blog/loading.tsx @@ -0,0 +1,50 @@ +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; + +export default function BlogLoading() { + return ( +
+ {/* Background Effects */} +
+
+
+
+ + + +
+
+ {/* Hero Section Skeleton */} +
+
+
+
+ + {/* Blog Grid Skeleton */} +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ {/* Image Skeleton */} +
+ + {/* Content Skeleton */} +
+
+
+
+
+
+
+
+ ))} +
+
+
+ +
+
+ ); +} diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx new file mode 100644 index 0000000..7c3a25e --- /dev/null +++ b/src/app/blog/page.tsx @@ -0,0 +1,118 @@ +import { Metadata } from 'next'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { BlogGrid } from '@/components/blog/BlogGrid'; +import { BlogPagination } from '@/components/blog/BlogPagination'; +import { BlogError } from '@/components/blog/BlogError'; +import { + getGhostPosts, + getGhostErrorMessage, + type GhostPost, + type GhostPagination, +} from '@/lib/services/ghost'; + +export const metadata: Metadata = { + title: 'Blog - Etags', + description: + 'Insights, berita terbaru, dan artikel mendalam tentang blockchain, product authentication, dan teknologi Web3.', + keywords: [ + 'blog', + 'blockchain', + 'product authentication', + 'web3', + 'etags insights', + ], + openGraph: { + title: 'Blog - Etags', + description: + 'Insights tentang blockchain, product authentication, dan teknologi Web3.', + }, +}; + +// Force dynamic rendering to handle pagination correctly +export const dynamic = 'force-dynamic'; +export const revalidate = 60; // Revalidate every 60 seconds + +interface BlogPageProps { + searchParams: Promise<{ page?: string }>; +} + +export default async function BlogPage({ searchParams }: BlogPageProps) { + const params = await searchParams; + const currentPage = Number(params.page) || 1; + + let posts: GhostPost[] = []; + let pagination: GhostPagination | null = null; + let error: string | null = null; + + try { + const data = await getGhostPosts(currentPage); + posts = data.posts || []; + pagination = data.meta?.pagination || null; + } catch (e) { + // Log error details in development + if (process.env.NODE_ENV === 'development') { + console.error('Failed to fetch blog posts:', e); + } + + error = getGhostErrorMessage(e); + } + + return ( +
+ {/* Background Effects */} +
+
+
+
+ + + +
+
+ {/* Hero Section */} +
+

+ Blog Etags +

+

+ Insights, berita terbaru, dan artikel mendalam tentang blockchain, + product authentication, dan teknologi Web3. +

+
+ + {/* Error State */} + {error ? ( + + ) : ( + <> + {/* Blog Grid */} + + + {/* Pagination */} + {pagination && pagination.pages > 1 && ( + + )} + + {/* Empty State */} + {posts.length === 0 && !error && ( +
+

+ Belum ada artikel yang dipublikasikan. +

+
+ )} + + )} +
+
+ +
+
+ ); +} diff --git a/src/app/careers/page.tsx b/src/app/careers/page.tsx new file mode 100644 index 0000000..6a0cb37 --- /dev/null +++ b/src/app/careers/page.tsx @@ -0,0 +1,55 @@ +import { Metadata } from 'next'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { + CareersHero, + ValuesCards, + BenefitsGrid, + PositionsList, + CareersCTA, +} from '@/components/careers'; + +export const metadata: Metadata = { + title: 'Careers - Etags', + description: + 'Join our team building blockchain solutions for product authentication. Remote-first, work-life balance, and cutting-edge technology. View open positions.', + keywords: [ + 'careers', + 'jobs', + 'blockchain jobs', + 'remote work', + 'web3 careers', + 'etags careers', + ], + openGraph: { + title: 'Careers - Etags', + description: + 'Build your career with us. Remote-first, work-life balance, latest tech.', + }, +}; + +export default function CareersPage() { + return ( +
+ {/* Background Effects */} +
+
+
+
+ + + +
+
+ + + + + +
+
+ +
+
+ ); +} diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx new file mode 100644 index 0000000..cecbe60 --- /dev/null +++ b/src/app/contact/page.tsx @@ -0,0 +1,61 @@ +import { Metadata } from 'next'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { + ContactHero, + ContactReasons, + ContactForm, + ContactInfo, + ContactCTA, +} from '@/components/contact'; + +export const metadata: Metadata = { + title: 'Contact Us - Etags', + description: + 'Get in touch with Etags team. We are here to help with sales inquiries, technical support, and partnership opportunities.', + keywords: [ + 'contact', + 'support', + 'sales', + 'partnership', + 'demo', + 'etags contact', + ], + openGraph: { + title: 'Contact Us - Etags', + description: + 'Have questions or want a demo? Our team is ready to help you.', + }, +}; + +export default function ContactPage() { + return ( +
+ {/* Background Effects */} +
+
+
+
+ + + +
+
+ + + +
+
+ +
+ +
+ + +
+
+ +
+
+ ); +} diff --git a/src/app/features/page.tsx b/src/app/features/page.tsx new file mode 100644 index 0000000..57ab009 --- /dev/null +++ b/src/app/features/page.tsx @@ -0,0 +1,54 @@ +import { Metadata } from 'next'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { + FeaturesHero, + FeaturesList, + BenefitsSection, + IntegrationsGrid, + FeaturesCTA, +} from '@/components/features'; + +export const metadata: Metadata = { + title: 'Features - Etags', + description: + 'Complete blockchain-based product authentication platform with AI fraud detection, NFT collectibles, geospatial tracking, and real-time analytics.', + keywords: [ + 'blockchain authentication', + 'AI fraud detection', + 'NFT collectibles', + 'product verification', + 'anti-counterfeiting', + ], + openGraph: { + title: 'Features - Etags', + description: + 'All-in-one platform with blockchain, AI, and analytics to protect your brand from counterfeiting.', + }, +}; + +export default function FeaturesPage() { + return ( +
+ {/* Background Effects */} +
+
+
+
+ + + +
+
+ + + + + +
+
+ +
+
+ ); +} diff --git a/src/app/manage/nfts/[id]/page.tsx b/src/app/manage/nfts/[id]/page.tsx index 3b55391..cd8eea4 100644 --- a/src/app/manage/nfts/[id]/page.tsx +++ b/src/app/manage/nfts/[id]/page.tsx @@ -1,8 +1,9 @@ import { notFound } from 'next/navigation'; import Link from 'next/link'; +import Image from 'next/image'; import { getNFTById } from '@/lib/actions/nfts'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; + import { Button } from '@/components/ui/button'; import { ArrowLeft, @@ -66,10 +67,12 @@ export default async function NFTDetailPage({ params }: NFTDetailPageProps) {
{nft.imageUrl ? ( - {`NFT ) : ( @@ -185,10 +188,13 @@ export default async function NFTDetailPage({ params }: NFTDetailPageProps) {
{nft.brand.logoUrl && ( - {nft.brand.name} )} {nft.brand.name} diff --git a/src/app/manage/nfts/page.tsx b/src/app/manage/nfts/page.tsx index db72557..69179e0 100644 --- a/src/app/manage/nfts/page.tsx +++ b/src/app/manage/nfts/page.tsx @@ -12,6 +12,7 @@ import { import { Badge } from '@/components/ui/badge'; import { Sparkles, TrendingUp, Calendar, ImageIcon } from 'lucide-react'; import Link from 'next/link'; +import Image from 'next/image'; import { formatAddress, getTxExplorerUrl, @@ -117,10 +118,12 @@ async function NFTTable() {
{nft.imageUrl ? ( - {`NFT ) : (
diff --git a/src/app/manage/tickets/components/sidebar-cards.tsx b/src/app/manage/tickets/components/sidebar-cards.tsx index f7388ba..d8ed188 100644 --- a/src/app/manage/tickets/components/sidebar-cards.tsx +++ b/src/app/manage/tickets/components/sidebar-cards.tsx @@ -1,5 +1,6 @@ 'use client'; +import Image from 'next/image'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { FileText, @@ -165,11 +166,13 @@ export function ProductSidebar({ tag, products }: ProductSidebarProps) { {tag.nft?.image_url && (

NFT Collectible

-
- + NFT
diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx new file mode 100644 index 0000000..9a82e41 --- /dev/null +++ b/src/app/pricing/page.tsx @@ -0,0 +1,53 @@ +import { Metadata } from 'next'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { + PricingHero, + PricingCards, + IncludedFeatures, + PricingFAQs, + PricingCTA, +} from '@/components/pricing'; + +export const metadata: Metadata = { + title: 'Pricing - Etags', + description: + 'Transparent pricing for blockchain-based product authentication. Start free with 1,000 tags per month. Upgrade anytime.', + keywords: [ + 'pricing', + 'product authentication pricing', + 'blockchain pricing', + 'anti-counterfeiting cost', + ], + openGraph: { + title: 'Pricing - Etags', + description: + 'Choose the right plan for your business. Start free, upgrade anytime.', + }, +}; + +export default function PricingPage() { + return ( +
+ {/* Background Effects */} +
+
+
+
+ + + +
+
+ + + + + +
+
+ +
+
+ ); +} diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx new file mode 100644 index 0000000..185ba50 --- /dev/null +++ b/src/app/privacy/page.tsx @@ -0,0 +1,168 @@ +import { Metadata } from 'next'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { LegalHero, LegalSection } from '@/components/legal'; + +export const metadata: Metadata = { + title: 'Privacy Policy - Etags', + description: + 'Learn how Etags collects, uses, and protects your data. We are committed to transparency and data privacy.', + keywords: ['privacy policy', 'data protection', 'GDPR', 'data privacy'], + openGraph: { + title: 'Privacy Policy - Etags', + description: 'Our commitment to protecting your data and privacy.', + }, +}; + +export default function PrivacyPage() { + return ( +
+
+
+
+ + + +
+
+ + +
+ +

+ 1.1 Informasi Akun +

+

+ Ketika Anda mendaftar sebagai pengguna Etags, kami mengumpulkan + informasi seperti nama, email, nama perusahaan, dan kata sandi + terenkripsi. Informasi ini digunakan untuk membuat dan mengelola + akun Anda. +

+ +

+ 1.2 Data Produk dan Tag +

+

+ Kami menyimpan informasi produk yang Anda upload, termasuk nama + produk, deskripsi, gambar, dan metadata lainnya. Data QR code + dan transaksi blockchain juga dicatat untuk verifikasi keaslian. +

+ +

+ 1.3 Data Scan +

+

+ Ketika konsumen scan QR code produk, kami mengumpulkan data + seperti waktu scan, lokasi (jika diizinkan), fingerprint browser + untuk mendeteksi fraud, dan informasi device. +

+ +

+ 1.4 Data Web3 +

+

+ Untuk fitur NFT collectible dan support ticket, kami + mengumpulkan wallet address, transaction hash, dan metadata NFT. + Kami tidak pernah meminta private key atau seed phrase. +

+
+ + +

+ Informasi digunakan untuk menyediakan, memelihara, dan + meningkatkan layanan Etags, termasuk verifikasi produk, + dashboard analytics, dan NFT minting. +

+

+ Data scan dianalisis menggunakan AI untuk mendeteksi pola + mencurigakan dan melindungi brand dari pemalsuan. Kami + menggunakan Kolosal AI dengan enkripsi end-to-end. +

+

+ Kami menggunakan email Anda untuk mengirim notifikasi penting, + update produk, dan newsletter (dapat unsubscribe kapan saja). +

+
+ + +

+ Semua data sensitif dienkripsi menggunakan AES-256 encryption. + Kata sandi di-hash dengan bcrypt dan tidak dapat dibaca dalam + bentuk plain text. +

+

+ Kami menggunakan HTTPS untuk semua komunikasi dan implementasi + rate limiting untuk mencegah abuse. Database kami di-backup + secara teratur dan disimpan secara terenkripsi. +

+

+ Akses ke data dibatasi dengan role-based access control (RBAC) + dan semua aktivitas dicatat untuk audit trail. +

+
+ + +

+ Kami tidak menjual data pribadi Anda kepada pihak ketiga. Data + hanya dibagikan dalam kondisi berikut: +

+
    +
  • Dengan persetujuan eksplisit Anda
  • +
  • Untuk mematuhi hukum dan regulasi yang berlaku
  • +
  • + Untuk melindungi hak dan keamanan Etags dan pengguna lainnya +
  • +
  • + Dengan service provider terpercaya (Cloudflare R2, Base + Sepolia blockchain) dengan perjanjian kerahasiaan +
  • +
+
+ + +

+ Anda memiliki hak untuk mengakses, memperbarui, atau menghapus + data pribadi Anda kapan saja melalui dashboard atau dengan + menghubungi kami. +

+

+ Anda dapat mengekspor semua data Anda dalam format JSON dan + meminta penghapusan akun beserta semua data terkait. +

+

+ Untuk pertanyaan atau request terkait privasi, hubungi: + privacy@etags.id +

+
+ + +

+ Kami menggunakan cookies untuk menjaga sesi login dan mengingat + preferensi Anda. Anda dapat menonaktifkan cookies melalui + browser settings, namun beberapa fitur mungkin tidak berfungsi + optimal. +

+
+ + +

+ Kami dapat memperbarui kebijakan privasi ini sewaktu-waktu. + Perubahan signifikan akan diberitahukan via email dan tercantum + tanggal pembaruan di bagian atas dokumen ini. +

+
+
+
+
+ +
+
+ ); +} diff --git a/src/app/security/page.tsx b/src/app/security/page.tsx new file mode 100644 index 0000000..a69f1d1 --- /dev/null +++ b/src/app/security/page.tsx @@ -0,0 +1,254 @@ +import { Metadata } from 'next'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { LegalHero, LegalSection } from '@/components/legal'; + +export const metadata: Metadata = { + title: 'Security Practices - Etags', + description: + 'Learn about our security measures and best practices to protect your data and products.', + keywords: [ + 'security', + 'data protection', + 'cybersecurity', + 'blockchain security', + ], + openGraph: { + title: 'Security Practices - Etags', + description: 'Our commitment to security and data protection.', + }, +}; + +export default function SecurityPage() { + return ( +
+
+
+
+ + + +
+
+ + +
+ +

+ 1.1 Data in Transit +

+

+ Semua komunikasi antara browser Anda dan server kami menggunakan + TLS 1.3 encryption. Ini memastikan bahwa data tidak dapat dibaca + oleh pihak ketiga selama transmisi. +

+ +

+ 1.2 Data at Rest +

+

+ Data sensitif di database dienkripsi menggunakan AES-256 + encryption. Kata sandi di-hash menggunakan bcrypt dengan salt + unik per pengguna. +

+ +

+ 1.3 API Keys dan Secrets +

+

+ API keys dan environment variables disimpan secara terenkripsi + dan tidak pernah di-hardcode dalam source code. Kami menggunakan + secure secret management system. +

+
+ + +

+ 2.1 NextAuth v5 +

+

+ Platform menggunakan NextAuth v5 dengan credentials provider. + Session di-manage dengan secure HTTP-only cookies yang tidak + dapat diakses via JavaScript. +

+ +

+ 2.2 Role-Based Access Control (RBAC) +

+

+ User roles (admin, brand) dengan permission yang jelas. Brand + users hanya dapat mengakses data brand mereka sendiri dengan + brand_id isolation. +

+ +

+ 2.3 Web3 Authentication +

+

+ Untuk NFT features, user connect wallet via MetaMask. Kami tidak + pernah meminta atau menyimpan private keys. Semua transaksi + di-sign oleh user di browser mereka. +

+
+ + +

+ 3.1 Rate Limiting +

+

+ API endpoints dilindungi dengan rate limiting (misalnya, contact + form: 3 requests per 15 menit). Ini mencegah abuse dan DoS + attacks. +

+ +

+ 3.2 Input Validation +

+

+ Semua user input divalidasi di client-side dan server-side. Kami + sanitize input untuk mencegah XSS, SQL injection, dan command + injection attacks. +

+ +

+ 3.3 CSRF Protection +

+

+ Form submissions dilindungi dengan CSRF tokens untuk mencegah + cross-site request forgery attacks. +

+ +

+ 3.4 Content Security Policy (CSP) +

+

+ Kami implementasi CSP headers untuk mencegah XSS attacks dan + clickjacking. +

+
+ + +

+ 4.1 Smart Contract Audits +

+

+ Smart contracts (ETagRegistry, ETagCollectible) telah diaudit + dan tested secara komprehensif. Source code tersedia untuk + review. +

+ +

+ 4.2 Admin Wallet Security +

+

+ Admin wallet untuk NFT minting menggunakan hardware wallet dan + multi-signature setup untuk transaksi besar. +

+ +

+ 4.3 Immutable Records +

+

+ Tag stamping di blockchain bersifat immutable. Setelah tag + dicreate, data tidak dapat diubah, memastikan integritas + verifikasi. +

+
+ + +

5.1 Logging

+

+ Semua aktivitas penting dicatat (login, API calls, blockchain + transactions) tanpa menyimpan informasi sensitif seperti kata + sandi atau private keys. +

+ +

+ 5.2 Anomaly Detection +

+

+ AI fraud detection menganalisis pola scan untuk mendeteksi + aktivitas mencurigakan. Alert otomatis dikirim untuk aktivitas + anomaly. +

+ +

+ 5.3 Regular Security Audits +

+

+ Kami melakukan security audits berkala dan penetration testing + untuk mengidentifikasi dan memperbaiki vulnerability. +

+
+ + +

+ Database di-backup setiap hari dan disimpan secara terenkripsi + di multiple locations. Backup dapat di-restore dalam waktu 1 jam + untuk disaster recovery. +

+

+ Blockchain data bersifat permanen dan tersedia di Base Sepolia + network. +

+
+ + +

+ Dalam kasus security incident, kami memiliki prosedur response + yang jelas: +

+
    +
  • Identifikasi dan isolasi masalah dalam 1 jam
  • +
  • Notifikasi ke affected users dalam 24 jam
  • +
  • Root cause analysis dan remediation
  • +
  • Post-incident review dan improvement
  • +
+
+ + +

Etags compliant dengan:

+
    +
  • GDPR untuk data protection
  • +
  • OWASP Top 10 security best practices
  • +
  • Blockchain security standards
  • +
  • Indonesia data protection regulations
  • +
+
+ + +

Kami merekomendasikan user untuk:

+
    +
  • Menggunakan kata sandi yang kuat dan unik
  • +
  • Tidak membagikan credential kepada pihak lain
  • +
  • Logout setelah selesai menggunakan platform
  • +
  • + Verifikasi email notifications untuk aktivitas mencurigakan +
  • +
  • Menjaga keamanan wallet address dan private keys
  • +
+
+ + +

+ Jika Anda menemukan security vulnerability, harap laporkan ke: + security@etags.id. Kami berkomitmen untuk menangani laporan + dengan serius dan akan memberikan credit untuk responsible + disclosure. +

+
+
+
+
+ +
+
+ ); +} diff --git a/src/app/showcase/page.tsx b/src/app/showcase/page.tsx new file mode 100644 index 0000000..0836f6e --- /dev/null +++ b/src/app/showcase/page.tsx @@ -0,0 +1,54 @@ +import { Metadata } from 'next'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { + ShowcaseHero, + AchievementStats, + ShowcaseGrid, + TestimonialsSection, + ShowcaseCTA, +} from '@/components/showcase'; + +export const metadata: Metadata = { + title: 'Success Stories - Etags', + description: + 'See how leading brands use Etags to protect their products from counterfeiting across various industries including fashion, luxury goods, pharmaceutical, and electronics.', + keywords: [ + 'success stories', + 'case studies', + 'testimonials', + 'brand protection', + 'anti-counterfeiting', + ], + openGraph: { + title: 'Success Stories - Etags', + description: + '100+ brands trust Etags to protect their products from counterfeiting.', + }, +}; + +export default function ShowcasePage() { + return ( +
+ {/* Background Effects */} +
+
+
+
+ + + +
+
+ + + + + +
+
+ +
+
+ ); +} diff --git a/src/app/support/components/new-ticket-form.tsx b/src/app/support/components/new-ticket-form.tsx index 8b12fb0..5d566d4 100644 --- a/src/app/support/components/new-ticket-form.tsx +++ b/src/app/support/components/new-ticket-form.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import Image from 'next/image'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -121,10 +122,13 @@ export function NewTicketForm({ > {nft.image_url && ( - NFT )}
diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx new file mode 100644 index 0000000..0c27ec3 --- /dev/null +++ b/src/app/terms/page.tsx @@ -0,0 +1,194 @@ +import { Metadata } from 'next'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { LegalHero, LegalSection } from '@/components/legal'; + +export const metadata: Metadata = { + title: 'Terms & Conditions - Etags', + description: + 'Terms and conditions for using Etags platform. Please read carefully before using our services.', + keywords: ['terms', 'conditions', 'terms of service', 'user agreement'], + openGraph: { + title: 'Terms & Conditions - Etags', + description: 'Terms and conditions for using Etags platform.', + }, +}; + +export default function TermsPage() { + return ( +
+
+
+
+ + + +
+
+ + +
+ +

+ Dengan mengakses dan menggunakan platform Etags, Anda menyetujui + untuk terikat dengan syarat dan ketentuan ini. Jika Anda tidak + setuju, harap tidak menggunakan layanan kami. +

+
+ + +

+ 2.1 Akun Pengguna +

+

+ Anda bertanggung jawab untuk menjaga kerahasiaan akun dan kata + sandi Anda. Anda setuju untuk tidak membagikan akses akun kepada + pihak lain dan segera memberitahu kami jika terjadi akses tidak + sah. +

+ +

+ 2.2 Penggunaan yang Dilarang +

+

+ Anda setuju untuk tidak menggunakan platform untuk: +

+
    +
  • Aktivitas ilegal atau melanggar hukum
  • +
  • + Mengunggah konten yang melanggar hak cipta atau hak kekayaan + intelektual +
  • +
  • + Melakukan spamming, phishing, atau aktivitas berbahaya lainnya +
  • +
  • + Mencoba mengakses sistem atau data pengguna lain secara tidak + sah +
  • +
  • Menggunakan bot atau automated tools tanpa izin
  • +
+ +

+ 2.3 Konten Pengguna +

+

+ Anda mempertahankan semua hak atas konten yang Anda upload + (gambar produk, deskripsi, dll). Dengan mengunggah konten, Anda + memberikan Etags lisensi non-eksklusif untuk menggunakan konten + tersebut dalam rangka menyediakan layanan. +

+
+ + +

+ Etags menggunakan blockchain Base Sepolia untuk verifikasi + produk dan NFT minting. Transaksi blockchain bersifat permanen + dan tidak dapat diubah setelah dikonfirmasi. +

+

+ NFT yang di-mint melalui Etags adalah milik Anda sepenuhnya. + Anda bertanggung jawab untuk menjaga keamanan wallet Anda. +

+

+ Etags tidak bertanggung jawab atas kehilangan akses ke wallet + atau NFT akibat kelalaian Anda dalam menjaga private key atau + seed phrase. +

+
+ + +

+ Paket gratis (Starter) tersedia dengan limit 1.000 tag per + bulan. Paket berbayar (Professional, Enterprise) dikenakan biaya + sesuai pricing yang tercantum. +

+

+ Pembayaran diproses secara bulanan atau tahunan tergantung paket + yang dipilih. Refund dapat diajukan dalam 14 hari pertama untuk + paket berbayar. +

+

+ Kami berhak mengubah pricing dengan pemberitahuan 30 hari + sebelumnya. Perubahan tidak berlaku untuk langganan yang sudah + berjalan. +

+
+ + +

+ Kami berusaha menjaga uptime 99.9%, namun tidak menjamin layanan + akan selalu tersedia tanpa gangguan. Maintenance terjadwal akan + diberitahukan sebelumnya. +

+

+ Kami berhak membatasi atau menangguhkan akses ke layanan jika + terjadi pelanggaran syarat dan ketentuan atau aktivitas + mencurigakan. +

+
+ + +

+ Semua teknologi, software, dan brand assets Etags dilindungi + oleh hak cipta dan hak kekayaan intelektual. Anda tidak + diperkenankan untuk menyalin, memodifikasi, atau + mendistribusikan tanpa izin tertulis dari kami. +

+
+ + +

+ Etags tidak bertanggung jawab atas kerugian langsung atau tidak + langsung yang timbul dari penggunaan layanan, termasuk namun + tidak terbatas pada: +

+
    +
  • Kehilangan data atau kerusakan sistem
  • +
  • Kerugian bisnis atau kehilangan profit
  • +
  • Kerusakan reputasi
  • +
  • Kegagalan transaksi blockchain di luar kendali kami
  • +
+

+ Tanggung jawab maksimal kami terbatas pada jumlah yang Anda + bayarkan dalam 12 bulan terakhir. +

+
+ + +

+ Anda dapat menghentikan layanan kapan saja melalui dashboard. + Data Anda akan dihapus dalam 30 hari setelah penghentian. +

+

+ Kami berhak menghentikan layanan Anda tanpa pemberitahuan jika + terjadi pelanggaran berat terhadap syarat dan ketentuan. +

+
+ + +

+ Syarat dan ketentuan ini diatur oleh hukum Republik Indonesia. + Setiap sengketa akan diselesaikan melalui pengadilan di Jakarta, + Indonesia. +

+
+ + +

+ Untuk pertanyaan terkait syarat dan ketentuan, hubungi: + legal@etags.id +

+
+
+
+
+ +
+
+ ); +} diff --git a/src/app/verify/[code]/components/nft-claim-card.tsx b/src/app/verify/[code]/components/nft-claim-card.tsx index 60e0c72..dff794c 100644 --- a/src/app/verify/[code]/components/nft-claim-card.tsx +++ b/src/app/verify/[code]/components/nft-claim-card.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import Image from 'next/image'; import { Sparkles, Wallet, @@ -186,11 +187,13 @@ export function NFTClaimCard({
{nft?.imageUrl && ( -
- + NFT
)} @@ -222,11 +225,13 @@ export function NFTClaimCard({
{nftResult.imageUrl && ( -
- + Your NFT
)} diff --git a/src/components/about/AboutHero.tsx b/src/components/about/AboutHero.tsx new file mode 100644 index 0000000..5c08c50 --- /dev/null +++ b/src/components/about/AboutHero.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { motion, useReducedMotion } from 'framer-motion'; + +const MotionH1 = motion.h1; +const MotionP = motion.p; + +export function AboutHero() { + const reduceMotion = useReducedMotion(); + return ( +
+ + Tentang Etags + + + Platform verifikasi produk berbasis blockchain yang mengamankan rantai + pasokan dan memberikan kepercayaan penuh kepada konsumen. + +
+ ); +} diff --git a/src/components/about/MissionVision.tsx b/src/components/about/MissionVision.tsx new file mode 100644 index 0000000..ddc4389 --- /dev/null +++ b/src/components/about/MissionVision.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { motion } from 'framer-motion'; + +const MotionDiv = motion.div; + +export function MissionVision() { + return ( +
+ +

Misi Kami

+

+ Memberikan solusi autentikasi produk yang aman, transparan, dan mudah + digunakan untuk melindungi konsumen dari produk palsu dan meningkatkan + kepercayaan terhadap brand. +

+
+ + +

Visi Kami

+

+ Menjadi standar global untuk verifikasi keaslian produk dengan + teknologi blockchain, menciptakan ekosistem di mana setiap produk + dapat diverifikasi dengan mudah dan aman. +

+
+
+ ); +} diff --git a/src/components/about/StatsSection.tsx b/src/components/about/StatsSection.tsx new file mode 100644 index 0000000..19ef012 --- /dev/null +++ b/src/components/about/StatsSection.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { ABOUT_STATS } from '@/constants'; +import type { StatItem } from '@/types/common'; + +const MotionDiv = motion.div; + +export function StatsSection() { + const stats: StatItem[] = ABOUT_STATS; + return ( +
+ {stats.map((stat, index) => ( + +
+ {stat.value} +
+
{stat.label}
+
+ ))} +
+ ); +} diff --git a/src/components/about/TeamSection.tsx b/src/components/about/TeamSection.tsx new file mode 100644 index 0000000..f6cd9b3 --- /dev/null +++ b/src/components/about/TeamSection.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { Users } from 'lucide-react'; +import { ABOUT_TEAM } from '@/constants'; +import type { TeamMember } from '@/types/common'; + +const MotionDiv = motion.div; + +export function TeamSection() { + const team: TeamMember[] = ABOUT_TEAM as unknown as TeamMember[]; + return ( +
+

Tim Kami

+

+ Tim Pemuja Deadline Anti Refund - Developer berpengalaman yang + berdedikasi membangun solusi blockchain terbaik untuk IMPHEN 2025. +

+
+ {team.map((member, index) => ( + +
+ +
+

{member.name}

+

{member.role}

+ + GitHub Profile + +
+ ))} +
+
+ ); +} diff --git a/src/components/about/TechnologyStack.tsx b/src/components/about/TechnologyStack.tsx new file mode 100644 index 0000000..5832e64 --- /dev/null +++ b/src/components/about/TechnologyStack.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { ABOUT_TECH_STACK } from '@/constants'; +import type { TechStackItem } from '@/types/common'; + +const MotionDiv = motion.div; + +export function TechnologyStack() { + const techStack: TechStackItem[] = + ABOUT_TECH_STACK as unknown as TechStackItem[]; + return ( + +

+ Teknologi Kami +

+

+ Etags dibangun dengan teknologi terdepan untuk memberikan keamanan, + kecepatan, dan skalabilitas yang optimal. +

+
+ {techStack.map((tech) => ( +
+

{tech.title}

+

{tech.description}

+
+ ))} +
+
+ ); +} diff --git a/src/components/about/ValuesSection.tsx b/src/components/about/ValuesSection.tsx new file mode 100644 index 0000000..8fece56 --- /dev/null +++ b/src/components/about/ValuesSection.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { Shield, Target, Users, Zap } from 'lucide-react'; +import { ABOUT_VALUES } from '@/constants'; + +const MotionDiv = motion.div; + +// Map icons to the data +const values = ABOUT_VALUES.map((value, index) => ({ + ...value, + icon: [Shield, Target, Users, Zap][index], +})); + +export function ValuesSection() { + return ( +
+

+ Nilai-Nilai Kami +

+
+ {values.map((value, index) => ( + + +

+ {value.title} +

+

+ {value.description} +

+
+ ))} +
+
+ ); +} diff --git a/src/components/about/index.ts b/src/components/about/index.ts new file mode 100644 index 0000000..0da64bd --- /dev/null +++ b/src/components/about/index.ts @@ -0,0 +1,6 @@ +export { AboutHero } from './AboutHero'; +export { MissionVision } from './MissionVision'; +export { ValuesSection } from './ValuesSection'; +export { TechnologyStack } from './TechnologyStack'; +export { TeamSection } from './TeamSection'; +export { StatsSection } from './StatsSection'; diff --git a/src/components/blog/BlogError.tsx b/src/components/blog/BlogError.tsx new file mode 100644 index 0000000..c0925bb --- /dev/null +++ b/src/components/blog/BlogError.tsx @@ -0,0 +1,21 @@ +'use client'; + +import React from 'react'; + +interface BlogErrorProps { + message: string; +} + +export function BlogError({ message }: BlogErrorProps) { + return ( +
+

{message}

+ +
+ ); +} diff --git a/src/components/blog/BlogGrid.tsx b/src/components/blog/BlogGrid.tsx new file mode 100644 index 0000000..cb640a5 --- /dev/null +++ b/src/components/blog/BlogGrid.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { motion } from 'framer-motion'; +import Image from 'next/image'; +import { Calendar, ArrowRight } from 'lucide-react'; + +const MotionDiv = motion.div; + +interface BlogPost { + title: string; + excerpt: string; + url: string; + feature_image: string; + published_at: string; +} + +interface BlogGridProps { + posts: BlogPost[]; +} + +export function BlogGrid({ posts }: BlogGridProps) { + if (!posts || posts.length === 0) { + return ( +
+

+ Belum ada artikel yang tersedia saat ini. Kembali lagi nanti untuk + konten terbaru! +

+
+ ); + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('id-ID', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + return ( +
+ {posts.map((post, index) => ( + + {/* Featured Image */} +
+ {post.feature_image ? ( + {post.title} + ) : ( +
+
+
+ E +
+

Etags Blog

+
+
+ )} +
+ + {/* Content */} +
+ {/* Date */} +
+ + {formatDate(post.published_at)} +
+ + {/* Title */} +

+ {post.title} +

+ + {/* Excerpt */} +

+ {post.excerpt} +

+ + {/* Read More Link */} + + Baca Selengkapnya + + +
+
+ ))} +
+ ); +} diff --git a/src/components/blog/BlogPagination.tsx b/src/components/blog/BlogPagination.tsx new file mode 100644 index 0000000..7fe6d42 --- /dev/null +++ b/src/components/blog/BlogPagination.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface BlogPaginationProps { + currentPage: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; +} + +export function BlogPagination({ + currentPage, + totalPages, + hasNext, + hasPrev, +}: BlogPaginationProps) { + const router = useRouter(); + + const navigateToPage = async (page: number) => { + const url = page === 1 ? '/blog' : `/blog?page=${page}`; + await router.push(url); + router.refresh(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + // Generate page numbers to display + const getPageNumbers = () => { + const pages: (number | string)[] = []; + const maxVisible = 7; // Maximum number of page buttons to show + + if (totalPages <= maxVisible) { + // Show all pages if total is small + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // Always show first page + pages.push(1); + + if (currentPage <= 3) { + // Near the start + for (let i = 2; i <= 4; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } else if (currentPage >= totalPages - 2) { + // Near the end + pages.push('...'); + for (let i = totalPages - 3; i <= totalPages; i++) { + pages.push(i); + } + } else { + // In the middle + pages.push('...'); + pages.push(currentPage - 1); + pages.push(currentPage); + pages.push(currentPage + 1); + pages.push('...'); + pages.push(totalPages); + } + } + + return pages; + }; + + const pageNumbers = getPageNumbers(); + + return ( +
+ {/* Previous Button */} + + + {/* Page Numbers */} +
+ {pageNumbers.map((page, index) => { + if (page === '...') { + return ( + + ... + + ); + } + + const pageNum = page as number; + const isActive = pageNum === currentPage; + + return ( + + ); + })} +
+ + {/* Next Button */} + +
+ ); +} diff --git a/src/components/blog/README.md b/src/components/blog/README.md new file mode 100644 index 0000000..86c2ed7 --- /dev/null +++ b/src/components/blog/README.md @@ -0,0 +1,174 @@ +# Blog Component + +This directory contains components for the blog feature that integrates with Ghost CMS. + +## Components + +### BlogGrid + +Displays a grid of blog posts fetched from Ghost CMS API. + +**Props:** + +- `posts: BlogPost[]` - Array of blog posts from Ghost API + +**BlogPost Interface:** + +```typescript +interface BlogPost { + title: string; + excerpt: string; + url: string; + feature_image: string; + published_at: string; +} +``` + +### BlogPagination + +Pagination controls for navigating through blog posts. + +**Props:** + +- `currentPage: number` - Current active page +- `totalPages: number` - Total number of pages +- `hasNext: boolean` - Whether there's a next page +- `hasPrev: boolean` - Whether there's a previous page + +**Features:** + +- Smart page number display with ellipsis +- Previous/Next navigation buttons +- Active page highlighting +- URL-based navigation with search params +- Smooth scroll to top on page change + +## Setup + +1. **Get Ghost Content API Key:** + - Go to your Ghost admin panel + - Navigate to Settings → Integrations + - Create a new Custom Integration or use existing one + - Copy the Content API Key + +2. **Set Environment Variable:** + Add to your `.env` or `.env.local`: + + ``` + NEXT_PUBLIC_TOKEN=your_ghost_content_api_key_here + ``` + +3. **Configure API Endpoint:** + The blog page fetches from: `https://blog.javapixa.com/ghost/api/content/posts/` + + If you want to use a different Ghost blog, update the URL in `src/app/blog/page.tsx`: + + ```typescript + const result = await axios.get( + `https://your-blog-domain.com/ghost/api/content/posts/?key=${token}&limit=6&fields=title,excerpt,url,feature_image,published_at` + ); + ``` + +## Features + +- ✅ Server-side rendering for better SEO +- ✅ Loading skeleton during data fetch +- ✅ Error handling with user-friendly messages +- ✅ Responsive grid layout (1/2/3 columns) +- ✅ Featured image support with fallback +- ✅ Date formatting in Indonesian locale +- ✅ Hover animations and transitions +- ✅ External link handling (opens in new tab) +- ✅ Line clamping for title and excerpt +- ✅ Pagination with Ghost CMS API +- ✅ URL-based page navigation +- ✅ Smart pagination controls with ellipsis + +## Customization + +### Change Number of Posts Per Page + +Edit the `POSTS_PER_PAGE` constant in `src/app/blog/page.tsx`: + +```typescript +const POSTS_PER_PAGE = 9; // Change to your desired number +``` + +This will affect: + +- Number of posts shown per page +- Pagination page count calculation +- API request limit parameter + +### Add More Fields + +Add fields to the `fields` parameter: + +```typescript +`...&fields=title,excerpt,url,feature_image,published_at,tags,authors`; +``` + +### Styling + +All components use the project's design system colors: + +- Primary: `#2B4C7E` +- Dark: `#0C2340` +- Medium: `#1E3A5F` +- Gray: `#A8A8A8` +- Text: `#606060` + +## API Reference + +Ghost Content API documentation: https://ghost.org/docs/content-api/ + +**Endpoint:** `GET /ghost/api/content/posts/` + +**Query Parameters:** + +- `key` (required): Content API Key +- `limit`: Number of posts to return per page (default: 15) +- `page`: Page number for pagination (default: 1) +- `fields`: Comma-separated list of fields to include +- `filter`: Filter posts (e.g., `tag:blockchain`) +- `order`: Sort order (e.g., `published_at DESC`) +- `include`: Include related data (e.g., `tags,authors`) + +**Response Format:** + +```json +{ + "posts": [...], + "meta": { + "pagination": { + "page": 1, + "limit": 9, + "pages": 10, + "total": 87, + "next": 2, + "prev": null + } + } +} +``` + +## Troubleshooting + +### "Gagal memuat artikel blog" + +- Check if `NEXT_PUBLIC_TOKEN` is set in environment variables +- Verify the Ghost API endpoint is accessible +- Check if the Content API Key is valid +- Ensure CORS is configured on Ghost admin + +### No posts showing + +- Verify posts are published in Ghost (not drafts) +- Check the API response in browser network tab +- Try increasing the `limit` parameter + +### Images not loading + +- Verify `feature_image` URLs are accessible +- Check if Ghost CDN/storage is configured correctly +- The fallback UI will show if image URL is invalid diff --git a/src/components/blog/index.ts b/src/components/blog/index.ts new file mode 100644 index 0000000..eb147cd --- /dev/null +++ b/src/components/blog/index.ts @@ -0,0 +1,2 @@ +export { BlogGrid } from './BlogGrid'; +export { BlogPagination } from './BlogPagination'; diff --git a/src/components/careers/BenefitsGrid.tsx b/src/components/careers/BenefitsGrid.tsx new file mode 100644 index 0000000..9143f11 --- /dev/null +++ b/src/components/careers/BenefitsGrid.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { CAREER_BENEFITS } from '@/constants'; +import type { BenefitItem } from '@/types/common'; + +export function BenefitsGrid() { + const benefits: BenefitItem[] = CAREER_BENEFITS as unknown as BenefitItem[]; + return ( +
+

+ Benefit & Perks +

+
+ {benefits.map((benefit, index) => { + const Icon = benefit.icon; + return ( + +
+ +
+

+ {benefit.title} +

+

+ {benefit.description} +

+
+ ); + })} +
+
+ ); +} diff --git a/src/components/careers/CareersCTA.tsx b/src/components/careers/CareersCTA.tsx new file mode 100644 index 0000000..06cba63 --- /dev/null +++ b/src/components/careers/CareersCTA.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { Mail } from 'lucide-react'; + +export function CareersCTA() { + return ( + +

+ Tidak Menemukan Posisi yang Cocok? +

+

+ Kirim resume dan portfolio Anda ke kami. Kami selalu mencari talenta + yang passionate untuk bergabung dengan tim. +

+ + + Kirim CV Anda + +
+ ); +} diff --git a/src/components/careers/CareersHero.tsx b/src/components/careers/CareersHero.tsx new file mode 100644 index 0000000..5860972 --- /dev/null +++ b/src/components/careers/CareersHero.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { motion } from 'framer-motion'; + +export function CareersHero() { + return ( +
+ + Bangun Karir Bersama Kami + + + Bergabunglah dengan tim yang passionate dalam membangun solusi + blockchain untuk masa depan. Remote-first, work-life balance, dan + teknologi terdepan. + +
+ ); +} diff --git a/src/components/careers/PositionsList.tsx b/src/components/careers/PositionsList.tsx new file mode 100644 index 0000000..f35b7f7 --- /dev/null +++ b/src/components/careers/PositionsList.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { Briefcase, MapPin, Clock } from 'lucide-react'; +import { JOB_POSITIONS } from '@/constants'; +import type { JobPosition } from '@/types/common'; + +export function PositionsList() { + const positions: JobPosition[] = JOB_POSITIONS as unknown as JobPosition[]; + return ( +
+

+ Posisi Terbuka +

+
+ {positions.map((position, index) => ( + +
+
+

+ {position.title} +

+
+ + + {position.department} + + + + {position.type} + + + + {position.location} + +
+
+ + Apply Now + +
+ +

+ {position.description} +

+ +
+

+ Requirements: +

+
    + {position.requirements.map((req, i) => ( +
  • + + {req} +
  • + ))} +
+
+
+ ))} +
+
+ ); +} diff --git a/src/components/careers/ValuesCards.tsx b/src/components/careers/ValuesCards.tsx new file mode 100644 index 0000000..142fa50 --- /dev/null +++ b/src/components/careers/ValuesCards.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { CAREER_VALUES } from '@/constants'; + +export function ValuesCards() { + return ( +
+

+ Nilai-Nilai Kami +

+
+ {CAREER_VALUES.map((value, index) => { + const Icon = value.icon; + return ( + +
+ +
+

+ {value.title} +

+

+ {value.description} +

+
+ ); + })} +
+
+ ); +} diff --git a/src/components/careers/index.ts b/src/components/careers/index.ts new file mode 100644 index 0000000..a84f185 --- /dev/null +++ b/src/components/careers/index.ts @@ -0,0 +1,5 @@ +export { CareersHero } from './CareersHero'; +export { ValuesCards } from './ValuesCards'; +export { BenefitsGrid } from './BenefitsGrid'; +export { PositionsList } from './PositionsList'; +export { CareersCTA } from './CareersCTA'; diff --git a/src/components/contact/ContactCTA.tsx b/src/components/contact/ContactCTA.tsx new file mode 100644 index 0000000..3f92e53 --- /dev/null +++ b/src/components/contact/ContactCTA.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { motion } from 'framer-motion'; +import Link from 'next/link'; +import { ArrowRight, BookOpen } from 'lucide-react'; + +export function ContactCTA() { + return ( + +

+ Ingin Tahu Lebih Banyak? +

+

+ Jelajahi dokumentasi lengkap kami atau lihat FAQ untuk pertanyaan yang + sering diajukan. +

+
+ + Lihat FAQ + + + + + Dokumentasi API + +
+
+ ); +} diff --git a/src/components/contact/ContactForm.tsx b/src/components/contact/ContactForm.tsx new file mode 100644 index 0000000..e3aa5de --- /dev/null +++ b/src/components/contact/ContactForm.tsx @@ -0,0 +1,308 @@ +'use client'; + +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { Send } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + validateContactForm, + sanitizeContactForm, + type ContactFormData, + type ValidationError, +} from '@/lib/validations/contact'; + +export function ContactForm() { + const [formData, setFormData] = useState({ + name: '', + email: '', + company: '', + subject: '', + message: '', + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [fieldErrors, setFieldErrors] = useState>({}); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Reset field errors + setFieldErrors({}); + setIsSubmitting(true); + + try { + // Client-side validation + const sanitizedData = sanitizeContactForm(formData); + const validation = validateContactForm(sanitizedData); + + if (!validation.isValid) { + // Convert validation errors to field-keyed object + const errors: Record = {}; + validation.errors.forEach((err: ValidationError) => { + errors[err.field] = err.message; + }); + setFieldErrors(errors); + + toast.error('Validasi Gagal', { + description: 'Mohon periksa kembali form Anda', + }); + + setIsSubmitting(false); + return; + } + + // Submit to API + const response = await fetch('/api/contact', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(sanitizedData), + }); + + const result = await response.json(); + + if (!response.ok) { + // Handle specific error codes + if (result.code === 'RATE_LIMIT_EXCEEDED') { + toast.error('Terlalu Banyak Permintaan', { + description: + result.error || 'Silakan coba lagi dalam beberapa menit', + }); + } else if (result.code === 'VALIDATION_ERROR' && result.errors) { + // Server-side validation errors + const errors: Record = {}; + result.errors.forEach((err: ValidationError) => { + errors[err.field] = err.message; + }); + setFieldErrors(errors); + + toast.error('Validasi Gagal', { + description: result.error || 'Data form tidak valid', + }); + } else { + toast.error('Gagal Mengirim Pesan', { + description: result.error || 'Terjadi kesalahan server', + }); + } + + setIsSubmitting(false); + return; + } + + // Success + toast.success('Pesan Terkirim!', { + description: + result.message || + 'Terima kasih telah menghubungi kami. Kami akan segera merespons pesan Anda.', + }); + + // Reset form + setFormData({ + name: '', + email: '', + company: '', + subject: '', + message: '', + }); + } catch (error) { + // Network or unexpected errors + if (error instanceof TypeError && error.message.includes('fetch')) { + toast.error('Koneksi Bermasalah', { + description: + 'Tidak dapat terhubung ke server. Periksa koneksi internet Anda.', + }); + } else { + toast.error('Terjadi Kesalahan', { + description: + 'Silakan coba lagi atau hubungi kami via email di hello@etags.id', + }); + } + + // Log error in development only + if (process.env.NODE_ENV === 'development') { + console.error( + 'Contact form error:', + error instanceof Error ? error.message : 'Unknown error' + ); + } + } finally { + setIsSubmitting(false); + } + }; + + // Clear field error when user starts typing + const handleFieldChange = (field: keyof ContactFormData, value: string) => { + setFormData({ ...formData, [field]: value }); + if (fieldErrors[field]) { + setFieldErrors({ ...fieldErrors, [field]: '' }); + } + }; + + return ( + +

+ Kirim Pesan Kepada Kami +

+
+ {/* Name Field */} +
+ + handleFieldChange('name', e.target.value)} + className={`${ + fieldErrors.name + ? 'border-red-500 focus:ring-red-500' + : 'border-[#2B4C7E]/30 focus:ring-[#2B4C7E]' + }`} + disabled={isSubmitting} + required + /> + {fieldErrors.name && ( +

{fieldErrors.name}

+ )} +
+ + {/* Email Field */} +
+ + handleFieldChange('email', e.target.value)} + className={`${ + fieldErrors.email + ? 'border-red-500 focus:ring-red-500' + : 'border-[#2B4C7E]/30 focus:ring-[#2B4C7E]' + }`} + disabled={isSubmitting} + required + /> + {fieldErrors.email && ( +

{fieldErrors.email}

+ )} +
+ + {/* Company Field */} +
+ + handleFieldChange('company', e.target.value)} + className="border-[#2B4C7E]/30 focus:ring-[#2B4C7E]" + disabled={isSubmitting} + /> +
+ + {/* Subject Field */} +
+ + handleFieldChange('subject', e.target.value)} + className={`${ + fieldErrors.subject + ? 'border-red-500 focus:ring-red-500' + : 'border-[#2B4C7E]/30 focus:ring-[#2B4C7E]' + }`} + disabled={isSubmitting} + required + /> + {fieldErrors.subject && ( +

{fieldErrors.subject}

+ )} +
+ + {/* Message Field */} +
+ +