diff --git a/.env.example b/.env.example index 996cfda..5ffe994 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ +# Public application URL (Telegram requires HTTPS) +NEXT_PUBLIC_APP_URL=https://podzahr.com + +# Telegram webhook authentication (the bot URL and token are configured in Admin → Purchases) +TELEGRAM_WEBHOOK_SECRET=replace-with-a-long-random-secret + # ZarinPal Configuration ZARINPAL_MERCHANT_ID=your-merchant-id-here diff --git a/Dockerfile b/Dockerfile index b68dcba..42a3b7c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,60 +1,43 @@ -# Multi-stage build for Next.js application with Bun +ARG BUN_VERSION=1.3.11 -# Stage 1: Dependencies -FROM oven/bun:1-alpine AS deps +FROM oven/bun:${BUN_VERSION}-alpine AS base WORKDIR /app -# Copy package files -COPY package.json bun.lockb* ./ +ENV NEXT_TELEMETRY_DISABLED=1 -# Install dependencies with Bun +FROM base AS dependencies + +COPY package.json bun.lock ./ RUN bun install --frozen-lockfile -# Stage 2: Builder -FROM oven/bun:1-alpine AS builder -WORKDIR /app +FROM base AS builder -# Copy dependencies from deps stage -COPY --from=deps /app/node_modules ./node_modules +ENV NODE_ENV=production + +COPY --from=dependencies /app/node_modules ./node_modules COPY . . -# Set environment variables for build -ENV NEXT_TELEMETRY_DISABLED=1 -ENV NODE_ENV=production +# package.json forces the Next.js CLI to execute with Bun. +RUN bun run build -# Build the application with --bun flag to use Bun runtime -RUN bun --bun run build +FROM base AS runner -# Stage 3: Runner -FROM oven/bun:1-alpine AS runner -WORKDIR /app +ENV NODE_ENV=production \ + HOSTNAME=0.0.0.0 \ + PORT=3000 -ENV NODE_ENV=production -ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=builder --chown=bun:bun /app/public ./public +COPY --from=builder --chown=bun:bun /app/.next/standalone ./ +COPY --from=builder --chown=bun:bun /app/.next/static ./.next/static -# Create a non-root user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +# bun:sqlite writes the database and WAL files here. +RUN mkdir -p /app/data && chown -R bun:bun /app/data -# Copy necessary files from builder -COPY --from=builder /app/public ./public -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static +USER bun -# Create data directory for SQLite database -RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data - -# Change ownership to nextjs user -RUN chown -R nextjs:nodejs /app - -# Switch to non-root user -USER nextjs - -# Expose port 3000 EXPOSE 3000 -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD bun -e "fetch('http://127.0.0.1:3000').then(response => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))" -# Start the application with Bun runtime -CMD ["bun", "--bun", "run", "server.js"] +CMD ["bun", "server.js"] diff --git a/README.md b/README.md index ee2b48d..c9c64da 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,7 @@ A modern, interactive website showcasing a progressive rock composer and produce ### Prerequisites -- Node.js 18+ -- npm or yarn +- Bun 1.3+ ### Installation @@ -54,13 +53,13 @@ A modern, interactive website showcasing a progressive rock composer and produce 2. Install dependencies: ```bash -npm install +bun install ``` 3. Run the development server: ```bash -npm run dev +bun run dev ``` 4. Open [http://localhost:3000](http://localhost:3000) in your browser @@ -197,13 +196,33 @@ export const albums: Album[] = [ ## Build for Production ```bash -npm run build -npm start +bun run build +bun run start ``` +## Telegram Purchase Approval Bot + +The Telegram bot works as both a customer storefront and an approval channel. Customers can browse albums, add several albums to a cart, receive the configured card details, and submit either a text transaction ID or a receipt screenshot. After approval, the bot uploads every purchased track directly into the customer's Telegram chat. + +Authorized reviewers receive pending purchases with **Approve** and **Reject** buttons. They can also send `/pending` to list outstanding purchases or `/id` to see their Telegram user ID. + +1. Create a bot with BotFather and copy `.env.example` to `.env`. +2. Set a long random `TELEGRAM_WEBHOOK_SECRET`. `NEXT_PUBLIC_APP_URL` defaults to `https://podzahr.com` and can be overridden if needed. +3. Deploy the application. +4. In **Admin → Purchases**, set the Telegram API base URL, paste the BotFather token, customize the greeting, and enter the payment card number and cardholder name. The saved token is never returned to the browser again. +5. Click **Register webhook**. +6. Each reviewer must start a private chat with the bot and send `/id`. +7. Enter the reviewers' comma-separated Telegram user IDs in the same admin settings panel and save. + +Telegram calls `/api/telegram/webhook` with the configured secret. Every approval callback also verifies that the sender is still present in the SQLite-backed approver list. + +Album delivery uses the full track files configured for each album in the admin panel. The application downloads each file and uploads it to Telegram as a document after the purchase is approved. + ## Technologies Used - **Next.js 15**: React framework with App Router +- **Bun**: JavaScript runtime, package manager, and native SQLite driver +- **SQLite**: Persistent album, purchase, and payment data via `bun:sqlite` - **TypeScript**: Type-safe development - **Tailwind CSS**: Utility-first CSS framework - **Framer Motion**: Animation library diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index 59c98f8..a119884 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -10,7 +10,6 @@ import AdminLayout from '@/components/AdminLayout'; export default function AdminDashboard() { const { albums } = useAlbums(); - const [purchases, setPurchases] = useState([]); const [stats, setStats] = useState({ totalAlbums: 0, totalPurchases: 0, @@ -19,33 +18,27 @@ export default function AdminDashboard() { }); useEffect(() => { - // Load purchases from localStorage - const savedPurchases = localStorage.getItem('purchases'); - if (savedPurchases) { - const parsedPurchases = JSON.parse(savedPurchases); - setPurchases(parsedPurchases); - - // Calculate stats - const totalRevenue = parsedPurchases.reduce((total: number, purchase: Purchase) => { - const album = albums.find((a) => a.id === purchase.albumId); - return total + (album?.price || 0); - }, 0); - + const loadStats = async () => { + const response = await fetch('/api/purchases'); + if (!response.ok) return; + const allPurchases = await response.json() as Purchase[]; + const totalRevenue = allPurchases + .filter((purchase) => purchase.approvalStatus === 'approved') + .reduce((total, purchase) => { + const ids = purchase.albumIds || [purchase.albumId]; + return total + albums + .filter((album) => ids.includes(album.id)) + .reduce((subtotal, album) => subtotal + album.price, 0); + }, 0); setStats({ totalAlbums: albums.length, - totalPurchases: parsedPurchases.length, + totalPurchases: allPurchases.length, totalRevenue, - recentPurchases: parsedPurchases.slice(-5).reverse(), + recentPurchases: allPurchases.slice(0, 5), }); - } else { - setStats({ - totalAlbums: albums.length, - totalPurchases: 0, - totalRevenue: 0, - recentPurchases: [], - }); - } - }, []); + }; + void loadStats(); + }, [albums]); const statCards = [ { @@ -124,21 +117,26 @@ export default function AdminDashboard() { {stats.recentPurchases.length > 0 ? (
{stats.recentPurchases.map((purchase) => { - const album = albums.find((a) => a.id === purchase.albumId); + const ids = purchase.albumIds || [purchase.albumId]; + const purchaseAlbums = albums.filter((album) => ids.includes(album.id)); return (
-

{album?.title || 'Unknown Album'}

+

+ {purchaseAlbums.map((album) => album.title).join(', ') || 'Unknown Album'} +

{new Date(purchase.purchaseDate).toLocaleDateString()} at{' '} {new Date(purchase.purchaseDate).toLocaleTimeString()}

-

{formatPrice(album?.price || 0)}

+

+ {formatPrice(purchaseAlbums.reduce((total, album) => total + album.price, 0))} +

{purchase.transactionId.slice(0, 12)}...

diff --git a/app/admin/purchases/page.tsx b/app/admin/purchases/page.tsx index b5c0792..a2003eb 100644 --- a/app/admin/purchases/page.tsx +++ b/app/admin/purchases/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; -import { FaDownload, FaSearch, FaCheck, FaTimes, FaClock } from 'react-icons/fa'; +import { FaDownload, FaSearch, FaCheck, FaTimes, FaClock, FaTelegramPlane, FaSave } from 'react-icons/fa'; import { useAlbums } from '@/lib/AlbumsContext'; import { Purchase } from '@/lib/types'; import { formatPrice } from '@/lib/utils'; @@ -14,9 +14,20 @@ export default function AdminPurchasesPage() { const [searchTerm, setSearchTerm] = useState(''); const [filterStatus, setFilterStatus] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all'); const [loading, setLoading] = useState(false); + const [telegramIds, setTelegramIds] = useState(''); + const [telegramBaseUrl, setTelegramBaseUrl] = useState('https://api.telegram.org'); + const [telegramBotToken, setTelegramBotToken] = useState(''); + const [telegramGreeting, setTelegramGreeting] = useState('Welcome! Choose albums from the catalog below.'); + const [telegramCardNumber, setTelegramCardNumber] = useState(''); + const [telegramCardHolder, setTelegramCardHolder] = useState(''); + const [telegramConfigured, setTelegramConfigured] = useState(false); + const [telegramWebhookSecretConfigured, setTelegramWebhookSecretConfigured] = useState(false); + const [telegramSaving, setTelegramSaving] = useState(false); + const [telegramRegistering, setTelegramRegistering] = useState(false); + const [telegramMessage, setTelegramMessage] = useState(''); - const fetchPurchases = async () => { - setLoading(true); + const fetchPurchases = async (showLoading = true) => { + if (showLoading) setLoading(true); try { const response = await fetch('/api/purchases'); if (response.ok) { @@ -26,14 +37,93 @@ export default function AdminPurchasesPage() { } catch (error) { console.error('Error fetching purchases:', error); } finally { - setLoading(false); + if (showLoading) setLoading(false); } }; useEffect(() => { fetchPurchases(); + const refreshTimer = window.setInterval(() => fetchPurchases(false), 10_000); + fetch('/api/telegram/settings') + .then(async (response) => { + if (!response.ok) throw new Error('Failed to load Telegram settings'); + return response.json(); + }) + .then((data) => { + setTelegramIds(data.approvers.map((item: { userId: string }) => item.userId).join(', ')); + setTelegramBaseUrl(data.baseUrl || 'https://api.telegram.org'); + setTelegramGreeting(data.greetingText || 'Welcome! Choose albums from the catalog below.'); + setTelegramCardNumber(data.cardNumber || ''); + setTelegramCardHolder(data.cardHolderName || ''); + setTelegramConfigured(Boolean(data.botConfigured)); + setTelegramWebhookSecretConfigured(Boolean(data.webhookSecretConfigured)); + }) + .catch((error) => { + console.error('Error fetching Telegram settings:', error); + setTelegramMessage('Could not load Telegram settings.'); + }); + return () => window.clearInterval(refreshTimer); }, []); + const saveTelegramSettings = async () => { + const ids = telegramIds + .split(/[\s,]+/) + .map((id) => id.trim()) + .filter(Boolean); + + if (ids.some((id) => !/^\d+$/.test(id))) { + setTelegramMessage('Telegram user IDs must contain digits only.'); + return; + } + + setTelegramSaving(true); + setTelegramMessage(''); + try { + const response = await fetch('/api/telegram/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + approvers: ids.map((userId) => ({ userId })), + baseUrl: telegramBaseUrl, + greetingText: telegramGreeting, + cardNumber: telegramCardNumber, + cardHolderName: telegramCardHolder, + ...(telegramBotToken.trim() ? { botToken: telegramBotToken.trim() } : {}), + }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to save Telegram approvers'); + setTelegramIds(data.approvers.map((item: { userId: string }) => item.userId).join(', ')); + setTelegramBaseUrl(data.baseUrl); + setTelegramGreeting(data.greetingText); + setTelegramCardNumber(data.cardNumber); + setTelegramCardHolder(data.cardHolderName); + setTelegramBotToken(''); + setTelegramConfigured(Boolean(data.botConfigured)); + setTelegramWebhookSecretConfigured(Boolean(data.webhookSecretConfigured)); + setTelegramMessage('Telegram settings saved.'); + } catch (error) { + setTelegramMessage(error instanceof Error ? error.message : 'Failed to save Telegram approvers.'); + } finally { + setTelegramSaving(false); + } + }; + + const registerTelegramWebhook = async () => { + setTelegramRegistering(true); + setTelegramMessage(''); + try { + const response = await fetch('/api/telegram/settings', { method: 'POST' }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Failed to register Telegram webhook'); + setTelegramMessage(data.message); + } catch (error) { + setTelegramMessage(error instanceof Error ? error.message : 'Failed to register Telegram webhook.'); + } finally { + setTelegramRegistering(false); + } + }; + const handleApprove = async (purchaseId: number) => { try { const response = await fetch(`/api/purchases/${purchaseId}/approve`, { @@ -63,9 +153,10 @@ export default function AdminPurchasesPage() { }; const filteredPurchases = purchases.filter((purchase) => { - const album = albums.find((a) => a.id === purchase.albumId); + const purchaseAlbumIds = purchase.albumIds || [purchase.albumId]; + const purchaseAlbums = albums.filter((album) => purchaseAlbumIds.includes(album.id)); const matchesSearch = - album?.title.toLowerCase().includes(searchTerm.toLowerCase()) || + purchaseAlbums.some((album) => album.title.toLowerCase().includes(searchTerm.toLowerCase())) || purchase.transactionId.toLowerCase().includes(searchTerm.toLowerCase()) || purchase.customerName?.toLowerCase().includes(searchTerm.toLowerCase()) || purchase.email?.toLowerCase().includes(searchTerm.toLowerCase()); @@ -79,21 +170,24 @@ export default function AdminPurchasesPage() { const approvedPurchases = purchases.filter(p => p.approvalStatus === 'approved'); const totalRevenue = approvedPurchases.reduce((total, purchase) => { - const album = albums.find((a) => a.id === purchase.albumId); - return total + (album?.price || 0); + const purchaseAlbumIds = purchase.albumIds || [purchase.albumId]; + return total + albums + .filter((album) => purchaseAlbumIds.includes(album.id)) + .reduce((subtotal, album) => subtotal + album.price, 0); }, 0); const exportToCSV = () => { const headers = ['Date', 'Time', 'Transaction ID', 'Album', 'Price', 'Customer', 'Email', 'Phone', 'Status', 'Payment Method']; const rows = purchases.map((purchase) => { - const album = albums.find((a) => a.id === purchase.albumId); + const purchaseAlbumIds = purchase.albumIds || [purchase.albumId]; + const purchaseAlbums = albums.filter((album) => purchaseAlbumIds.includes(album.id)); const date = new Date(purchase.purchaseDate); return [ date.toLocaleDateString(), date.toLocaleTimeString(), purchase.transactionId, - album?.title || 'Unknown', - album?.price || 0, + purchaseAlbums.map((album) => album.title).join(' + ') || 'Unknown', + purchaseAlbums.reduce((total, album) => total + album.price, 0), purchase.customerName || '', purchase.email || '', purchase.phoneNumber || '', @@ -161,6 +255,133 @@ export default function AdminPurchasesPage() {
+
+
+
+
+ + + + {telegramConfigured ? 'Bot configured' : 'Bot token missing'} + +
+
+
+ + setTelegramBaseUrl(event.target.value)} + placeholder="https://api.telegram.org" + className="w-full px-4 py-3 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper" + /> +
+
+ + setTelegramBotToken(event.target.value)} + placeholder={telegramConfigured ? 'Saved — leave blank to keep it' : 'Paste the BotFather token'} + autoComplete="new-password" + className="w-full px-4 py-3 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper" + /> +
+
+
+ +