diff --git a/.dockerignore b/.dockerignore index 4c7db2f..f6c0283 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,11 @@ -Dockerfile -.dockerignore node_modules npm-debug.log -README.md +pnpm-debug.log .next +.env* docker -.git \ No newline at end of file +.git +.gitignore +README.md +*.swp +.vscode \ No newline at end of file diff --git a/README.md b/README.md index 1394a4d..8152fc4 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,14 @@ Full-stack e-commerce store for developer laptop stickers. Automated with dropsh https://github.com/user-attachments/assets/d32962b3-6aa8-401c-ab43-440fff3e31cc -SwagSticker.com system diagram - ## 🎯 Project Overview - - -- designed autonomous and scalable e-commerce store -- enabled both guest checkout and no-password login for seamless checkout experience (JSON-Web-Token (JWT)) -- designed an accessible, responsive, and performant UI with Next.js and TailwindCSS -- utilized open-source SDKs to integrate third-party APIs -- reduced initial load time to 1.2secs -- generated product images with OpenCV python script -- optimized load times and SEO with server-side rendering -- avoided complex database setup with JSON product catalog and GraphQL for easy migration to headless CMS -- secured checkout payments with stripe forms and bot detection -- load test, >95% success rate with <300ms response for up to XXX users peak traffic +- Developed an autonomous, full-stack **e-commerce** store using **Next.js** and **TypeScript**, supporting automated dropshipping via **Printify SDK** +- Implemented **passwordless authentication** using JSON Web Tokens (JWT) for a secure, seamless checkout experience +- Integrated **Stripe SDK** with embedded payment forms and Webhooks for secure, automated payment processing, including bot detection for fraud prevention +- Enhanced page load speed and SEO with **server-side rendering**, achieving sub-2.5s initial load times +- Scaled to handle 1,500+ monthly active users, supporting up to 50 requests per second during peak traffic +- Conducted load tests to validate system reliability, achieving a >95% success rate with **P90 response times** between under 300 ms ## 🛠️ Built With @@ -40,6 +32,10 @@ https://github.com/user-attachments/assets/d32962b3-6aa8-401c-ab43-440fff3e31cc ![Account Page Feature](./.github/feature-view-orders.png) _View orders, download receipts, and track shipping status._ +## 🏗️ System Diagram + +SwagSticker.com system diagram + ## 💻 Local Development #### Prerequisites @@ -70,6 +66,8 @@ npm run dev # visit http://locahost:3000 ``` +## 🐋 Docker + #### Local Docker Container ```sh @@ -77,16 +75,17 @@ stripe login stripe listen --forward-to localhost:3000/api/v1/webhook/checkout # *open separate terminal* -cp .env.template .env.development -docker-compose -f ./docker/docker-compose.dev.yml --env-file .env.development up --build +cp .env.template .env.production +docker-compose -f ./docker/docker-compose.dev.yml --env-file .env.production up --build # visit http://locahost:3000 ``` -#### Production Build +#### Production Docker Build ```sh cp .env.template .env.production -npm run build +docker-compose -f ./docker/docker-compose.prod.yml --env-file .env.production up --build +# visit http://localhost ``` ## License diff --git a/docker/Dockerfile b/docker/Dockerfile index bb57d92..81b554a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,31 +1,23 @@ FROM node:20.16-alpine3.19 AS base -# 1. Install dependencies and build -FROM --platform=$BUILDPLATFORM base AS builder +# enable pnpm - https://pnpm.io/docker +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable # source: https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine RUN apk add --no-cache libc6-compat +# 1. Install dependencies and build +FROM --platform=$BUILDPLATFORM base AS builder + WORKDIR /app -# Install dependencies based on the preferred package manager -# COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ -# RUN \ -# if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ -# elif [ -f package-lock.json ]; then npm ci; \ -# elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i; \ -# else echo "Lockfile not found." && exit 1; \ -# fi - -# Install dependencies with pnpm (with cache) -RUN npm install -g pnpm +# Copy lockfile and install dependencies COPY package.json pnpm-lock.yaml ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile -# Copy source code and environment files COPY . . -COPY .env.production .env.development ./ - RUN pnpm build # 2. Production image, copy all the files and run @@ -40,7 +32,7 @@ ENV NODE_ENV=production \ PORT=3000 \ HOSTNAME="0.0.0.0" -COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone /app # https://nextjs.org/docs/pages/api-reference/next-config-js/output -CMD ["node", ".next/standalone/server.js"] \ No newline at end of file +CMD ["node", "./server.js"] \ No newline at end of file diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index d164fa5..f8ce43c 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -4,4 +4,7 @@ services: context: ../ dockerfile: docker/Dockerfile ports: - - "3000:3000" \ No newline at end of file + - "3000:3000" + env_file: + - ../.env.production + restart: always \ No newline at end of file diff --git a/next.config.mjs b/next.config.js similarity index 56% rename from next.config.mjs rename to next.config.js index bf1cf3c..74fad98 100644 --- a/next.config.mjs +++ b/next.config.js @@ -1,9 +1,13 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + productionBrowserSourceMaps: false, + trailingSlash: false, + experimental: { + optimizePackageImports: ['react-icons'], + }, swcMinify: true, output: 'standalone', // for Docker - trailingSlash: false, }; -export default nextConfig; +module.exports = nextConfig \ No newline at end of file diff --git a/package.json b/package.json index 9dae666..8fd2428 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "react-icons": "^5.3.0", "sharp": "0.32.6", "stripe": "^16.9.0", - "tailwindcss-animate": "^1.0.7", "use-shopping-cart": "^3.2.0", "winston": "^3.15.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27587a2..8dd00c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,9 +47,6 @@ importers: stripe: specifier: ^16.9.0 version: 16.12.0 - tailwindcss-animate: - specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.14) use-shopping-cart: specifier: ^3.2.0 version: 3.2.0(react@18.3.1)(redux@4.2.1) @@ -2066,11 +2063,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tailwindcss-animate@1.0.7: - resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders' - tailwindcss@3.4.14: resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==} engines: {node: '>=14.0.0'} @@ -4468,10 +4460,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.14): - dependencies: - tailwindcss: 3.4.14 - tailwindcss@3.4.14: dependencies: '@alloc/quick-lru': 5.2.0 diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index a4b5931..752d563 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -11,7 +11,6 @@ export default async function AccountPage() { const { email, error } = verifyJwt(token); if (error) return notFound(); - console.log(email); const orders = await getOrders(email as string); return ( diff --git a/src/app/api/v1/auth/logout/route.ts b/src/app/api/v1/auth/logout/route.ts index 7eb1b2d..f51d75a 100644 --- a/src/app/api/v1/auth/logout/route.ts +++ b/src/app/api/v1/auth/logout/route.ts @@ -7,7 +7,7 @@ export const POST = async () => { response.cookies.set('authToken', '', { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: true, maxAge: 0, path: '/', }); diff --git a/src/app/api/v1/status/route.ts b/src/app/api/v1/status/route.ts new file mode 100644 index 0000000..e0db66a --- /dev/null +++ b/src/app/api/v1/status/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import logger from '@/lib/logger'; +import { checkPrintifyStatus } from '@/lib/printify'; +import { checkStripeStatus } from '@/lib/stripe'; + +export async function GET() { + try { + const printifyStatus = await checkPrintifyStatus(); + const stripeStatus = await checkStripeStatus(); + const status = printifyStatus === "operational" && stripeStatus === "operational" ? "operational" : "degraded"; + + if (status === "degraded") { + logger.error('[Status] report:', { status, printifyStatus, stripeStatus }); + } + + return NextResponse.json({ status }); + } catch (error) { + logger.error('[Status] Error checking status', { error }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + diff --git a/src/app/order-confirmation/page.tsx b/src/app/order-confirmation/page.tsx index c0e44f5..ef2caf9 100644 --- a/src/app/order-confirmation/page.tsx +++ b/src/app/order-confirmation/page.tsx @@ -2,6 +2,8 @@ import OrderConfirmation from '@/components/OrderConfirmation'; import { stripe } from '@/lib/stripe'; import { notFound } from 'next/navigation'; +export const dynamic = 'force-dynamic'; // Dynamic server-side rendering + async function getSession(sessionId: string) { try { const session = await stripe.checkout.sessions.retrieve(sessionId!); diff --git a/src/lib/printify.ts b/src/lib/printify.ts index c8bf08a..be53a76 100644 --- a/src/lib/printify.ts +++ b/src/lib/printify.ts @@ -22,17 +22,15 @@ const PRINTIFY_VARIANT_IDS = { [STICKER_SIZES.FOUR_BY_FOUR_IN]: 95745, }; -// export const formatCartItemsForPrintify = (cartItems: CartItem[]): PrintifyLineItem[] => { -// return cartItems.map(cartItem => ({ -// print_provider_id: PRINTIFY_PRINT_PROVIDER_ID, -// blueprint_id: PRINTIFY_BLUEPRINT_ID, -// variant_id: PRINTIFY_VARIANT_IDS[cartItem.product_data.size], -// print_areas: { -// front: retrieveStickerPNGFileUrl(cartItem.product_data.productId), -// }, -// quantity: cartItem.quantity, -// })); -// }; +export async function checkPrintifyStatus() { + try { + await printify.shops.list(); + return "operational"; + } catch (error) { + logger.error('[Status] Printify status check failed', { error }); + return "degraded"; + } +} export const formatCartItemsForPrintify = (lineItems: StripeType.LineItem[]): PrintifyLineItem[] => { return lineItems.map(item => ({ diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index 8279e71..6c1f6f4 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -15,6 +15,16 @@ export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { }, }); +export async function checkStripeStatus() { + try { + await stripe.accounts.list({ limit: 1 }); + return "operational"; + } catch (error) { + logger.error('[Status] Stripe status check failed', { error }); + return "degraded"; + } +} + export const formatCartItemsForStripe = (cartItems: CartItem[]): Stripe.Checkout.SessionCreateParams.LineItem[] => { return cartItems.map(cartItem => { const lineItem = {