diff --git a/.dockerignore b/.dockerignore index cd12b7c..d561215 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,21 @@ +node_modules +dist +build +.env +.env.local Dockerfile +docker-compose.yml +docker-compose.prod.yml +.git +.gitignore +.vscode +.idea +*.log +*.tsbuildinfo +coverage .dockerignore -node_modules -npm-debug.log README.md .next -.git .env.example -mysql-init-scripts -docker-compose.yml \ No newline at end of file +.es +.github \ No newline at end of file diff --git a/.env.example b/.env.example index 47ad158..38834f6 100644 --- a/.env.example +++ b/.env.example @@ -9,10 +9,12 @@ SMTP_PASSWORD= EMAIL_DEV=dev@email.com # Backend Configuration -# use "host.docker.internal" as host if you are connecting to a MySQL database running on the host machine +# use "host.docker.internal" as host if you are connecting to +# a MySQL database running on the host machine while the backend +# is running in a Docker container SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/commercifydb?createDatabaseIfNotExist=true -SPRING_DATASOURCE_USERNAME=root -SPRING_DATASOURCE_PASSWORD=Password1234! +SPRING_DATASOURCE_USERNAME= +SPRING_DATASOURCE_PASSWORD= STRIPE_TEST_SECRET=sk_test_ STRIPE_SECRET_WEBHOOK=whsec_ STRIPE_WEBHOOK_ENDPOINT=we_ diff --git a/.gitignore b/.gitignore index e44db61..b4f1c55 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts .env -.vscode \ No newline at end of file +.vscode + +docker-compose.prod.yml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index aacaf5b..ad63f31 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,18 @@ FROM build AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . + +ARG NODE_ENV='production' +ENV NODE_ENV=$NODE_ENV + +ARG NEXT_PUBLIC_COMMERCIFY_API_URL="https://dev.commercify.app/api/v1" +ENV NEXT_PUBLIC_COMMERCIFY_API_URL=$NEXT_PUBLIC_COMMERCIFY_API_URL + +ARG NEXT_PUBLIC_DEV_COMMERCIFY_API_URL="http://localhost:6091/api/v1" +ENV NEXT_PUBLIC_DEV_COMMERCIFY_API_URL=$NEXT_PUBLIC_DEV_COMMERCIFY_API_URL + +RUN echo "Application is running in ${NODE_ENV} mode" + RUN npm run build # Production image, copy all the files and run next @@ -43,4 +55,5 @@ ENV PORT=3000 # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output ENV HOSTNAME="0.0.0.0" + CMD ["node", "server.js"] \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 9ea94c5..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,96 +0,0 @@ -services: - watchtower: - image: containrrr/watchtower - command: - - "--label-enable" - - "--interval" - - "30" - - "--rolling-restart" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - reverse-proxy: - image: traefik:v3.1 - command: - - "--providers.docker" - - "--providers.docker.exposedbydefault=false" - - "--entryPoints.websecure.address=:443" - - "--certificatesresolvers.myresolver.acme.tlschallenge=true" - - "--certificatesresolvers.myresolver.acme.email=commercify@zenfulcode.com" - - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" - - "--entrypoints.web.address=:80" - - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - - "--entrypoints.web.http.redirections.entrypoint.scheme=https" - ports: - - "80:80" - - "8080:8080" - - "443:443" - volumes: - - letsencrypt:/letsencrypt - - /var/run/docker.sock:/var/run/docker.sock - mysql-db: - image: docker.io/bitnami/mysql:8.4 - container_name: mysql-db - env_file: .env - environment: - ALLOW_EMPTY_PASSWORD: "no" - MYSQL_DATABASE: commercifydb - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} - MYSQL_USER: ${MYSQL_USER} - MYSQL_PASSWORD: ${MYSQL_PASSWORD} - expose: - - 3306 - volumes: - - mysql-data:/bitnami/mysql/data - # - ./mysql-init-scripts:/docker-entrypoint-initdb.d - healthcheck: - test: ["CMD", "/opt/bitnami/scripts/mysql/healthcheck.sh"] - interval: 15s - timeout: 5s - retries: 6 - - commercifyweb: - image: ghcr.io/zenfulcode/commercifyweb:dev - env_file: .env - labels: - - "traefik.enable=true" - - "traefik.http.routers.commercify.rule=Host(`commercify.app`)" - - "traefik.http.routers.commercify.entrypoints=websecure" - - "traefik.http.routers.commercify.tls.certresolver=myresolver" - - "com.centurylinklabs.watchtower.enable=true" - environment: - - NEXT_PUBLIC_COMMERCIFY_API_URL=https://commercify.app:6091/api/v1 - # deploy: - # mode: replicated - # replicas: 3 - depends_on: - - commercify - - commercify: - image: ghcr.io/zenfulcode/commercify:dev - env_file: .env - labels: - - "traefik.enable=true" - - "traefik.http.routers.commercify-api.rule=Host(`api.commercify.app`)" - - "traefik.http.routers.commercify-api.entrypoints=websecure" - - "traefik.http.routers.commercify-api.tls.certresolver=myresolver" - - "com.centurylinklabs.watchtower.enable=true" - environment: - - SPRING_PROFILES_ACTIVE=docker - - SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL} - - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} - - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} - - STRIPE_TEST_SECRET=${STRIPE_TEST_SECRET} - - JWT_SECRET_KEY=${JWT_SECRET_KEY} - expose: - - 6091 - # deploy: - # mode: replicated - # replicas: 3 - depends_on: - mysql-db: - condition: service_healthy - -volumes: - letsencrypt: - mysql-data: - driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 082451b..fae2321 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,49 +1,52 @@ services: - mysql-db: - image: docker.io/bitnami/mysql:8.4 - container_name: mysql-db + commercify-web: + # image: ghcr.io/zenfulcode/commercifyweb:dev + container_name: commercify-web + build: + context: . + args: + - NODE_ENV=production + - NEXT_PUBLIC_COMMERCIFY_API_URL=http://localhost:6091/api/v1 env_file: .env environment: - ALLOW_EMPTY_PASSWORD: "no" - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} - MYSQL_USER: ${MYSQL_USER} - MYSQL_PASSWORD: ${MYSQL_PASSWORD} + - NODE_ENV=${NODE_ENV} + - NEXT_PUBLIC_COMMERCIFY_API_URL=${NEXT_PUBLIC_COMMERCIFY_API_URL} + - NEXT_PUBLIC_DEV_COMMERCIFY_API_URL=${NEXT_PUBLIC_DEV_COMMERCIFY_API_URL} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - EMAIL_DEV=${EMAIL_DEV} ports: - - "3307:3306" + - "3000:3000" networks: - - spring-net - volumes: - - mysql-data:/bitnami/mysql/data - - ./mysql-init-scripts:/docker-entrypoint-initdb.d - healthcheck: - test: ["CMD", "/opt/bitnami/scripts/mysql/healthcheck.sh"] - interval: 15s - timeout: 5s - retries: 6 + - commercify - commercify: - image: ghcr.io/zenfulcode/commercify:dev + commercify-api: + image: ghcr.io/zenfulcode/commercify:main env_file: .env container_name: commercify-api - ports: - - "8080:8080" environment: - SPRING_PROFILES_ACTIVE=docker - - SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL} - - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} - - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} - - STRIPE_TEST_SECRET=${STRIPE_TEST_SECRET} + - SPRING_DATASOURCE_URL=${DATASOURCE_URL} + - SPRING_DATASOURCE_USERNAME=${DATASOURCE_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${DATASOURCE_PASSWORD} - JWT_SECRET_KEY=${JWT_SECRET_KEY} - depends_on: - mysql-db: - condition: service_healthy + - ADMIN_EMAIL=${ADMIN_EMAIL} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - MOBILEPAY_MERCHANT_ID=${MOBILEPAY_MERCHANT_ID} + - MOBILEPAY_CLIENT_ID=${MOBILEPAY_CLIENT_ID} + - MOBILEPAY_CLIENT_SECRET=${MOBILEPAY_CLIENT_SECRET} + - MOBILEPAY_SUBSCRIPTION_KEY=${MOBILEPAY_SUBSCRIPTION_KEY} + - MOBILEPAY_API_URL=${MOBILEPAY_API_URL} + - MOBILEPAY_SYSTEM_NAME=${MOBILEPAY_SYSTEM_NAME} + - STRIPE_SECRET_TEST_KEY=${STRIPE_SECRET_TEST_KEY} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} + ports: + - "6091:6091" networks: - - spring-net + - commercify networks: - spring-net: + commercify: driver: bridge - -volumes: - mysql-data: - driver: local diff --git a/src/app/(client)/checkout/_components/AuthenticationStep.tsx b/src/app/(client)/checkout/_components/AuthenticationStep.tsx index 33180f7..fa9ae16 100644 --- a/src/app/(client)/checkout/_components/AuthenticationStep.tsx +++ b/src/app/(client)/checkout/_components/AuthenticationStep.tsx @@ -40,8 +40,6 @@ export function AuthenticationStep() { resolver: zodResolver(loginSchema), }); - console.log(isAuthenticated); - if (isAuthenticated) { setStep('information'); return null; diff --git a/src/app/(client)/checkout/_components/PaymentStep.tsx b/src/app/(client)/checkout/_components/PaymentStep.tsx index ef325c1..6278b68 100644 --- a/src/app/(client)/checkout/_components/PaymentStep.tsx +++ b/src/app/(client)/checkout/_components/PaymentStep.tsx @@ -28,7 +28,7 @@ type PaymentMethod = typeof PAYMENT_METHODS[keyof typeof PAYMENT_METHODS]; export function PaymentStep() { const { toast } = useToast(); const { cart, clearCart } = useCart(); - const { state } = useCheckout(); + const { state, setStep } = useCheckout(); const { user } = useAuth(); const [isProcessing, setIsProcessing] = React.useState(false); const [selectedMethod, setSelectedMethod] = React.useState(PAYMENT_METHODS.MOBILEPAY); @@ -61,16 +61,12 @@ export function PaymentStep() { ...(item.selectedVariant && { variantId: item.selectedVariant.id }), })); - console.log(orderLines); - // Create order const orderResponse = await orderService.createOrder(user.id, { currency: "DKK", orderLines, }); - console.log(orderResponse); - // Create payment based on selected method if (selectedMethod === PAYMENT_METHODS.MOBILEPAY) { const paymentResponse = await orderService.createMobilePayPayment({ @@ -162,7 +158,7 @@ export function PaymentStep() { diff --git a/src/app/(client)/page.tsx b/src/app/(client)/page.tsx index 858fb52..f82c818 100644 --- a/src/app/(client)/page.tsx +++ b/src/app/(client)/page.tsx @@ -4,17 +4,11 @@ import React, { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { - AlertCircle, RefreshCw, ServerCrash, Wifi, WifiOff } from 'lucide-react'; -import { - Alert, - AlertDescription, - AlertTitle, -} from "@/components/ui/alert"; import { Product } from '@/types/product'; import { useToast } from '@/hooks/use-toast'; import { productApi } from '@/services/productsService'; @@ -67,14 +61,6 @@ export default function ProductsPage() { return (
- - - Error Loading Products - - We couldn't load the products at this time. - - -
diff --git a/src/app/(client)/products/[id]/page.tsx b/src/app/(client)/products/[id]/page.tsx deleted file mode 100644 index d442bcd..0000000 --- a/src/app/(client)/products/[id]/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { productApi } from "@/services/productsService"; -import ProductDetails from "./_components/ProductDetails"; - -interface ProductPageProps { - params: { - id: string; - }; - } - - export default async function ProductPage({ params }: ProductPageProps) { - const product = await productApi.getProductById(params.id); - - return ; - } \ No newline at end of file diff --git a/src/app/(client)/products/[id]/_components/ProductDetails.tsx b/src/app/(client)/products/[productId]/page.tsx similarity index 56% rename from src/app/(client)/products/[id]/_components/ProductDetails.tsx rename to src/app/(client)/products/[productId]/page.tsx index f9e1a04..2826c2f 100644 --- a/src/app/(client)/products/[id]/_components/ProductDetails.tsx +++ b/src/app/(client)/products/[productId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Product, ProductVariant } from '@/types/product'; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -14,22 +14,54 @@ import { import Image from 'next/image'; import placeholderImage from '@/public/placeholder.webp'; import { useRouter } from 'next/navigation'; -import { Breadcrumb, BreadcrumbItem, BreadcrumbLink } from '@/components/ui/breadcrumb'; -import { ChevronLeft } from 'lucide-react'; +import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb'; +import { ArrowLeft, ChevronLeft, Loader2 } from 'lucide-react'; import { useCart } from '@/context/CartContext'; +import { productApi } from '@/services/productsService'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; +import Link from 'next/link'; +import { useToast } from '@/hooks/use-toast'; -interface ProductDetailsProps { - product: Product; -} +export default function ProductDetails({ params }: { params: { productId: string } }) { + const [product, setProduct] = useState(null); -const ProductDetails: React.FC = ({ product }) => { const router = useRouter(); const { addToCart } = useCart(); - const [selectedVariant, setSelectedVariant] = React.useState( - product.variants?.[0] || null + const [selectedVariant, setSelectedVariant] = useState( + product?.variants?.[0] || undefined ); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const { toast } = useToast(); + + useEffect(() => { + const fetchProduct = async () => { + if (!params.productId) { + setError('Product not found'); + return; + } + + setIsLoading(true); + + try { + const response = await productApi.getProductById(params.productId); + setProduct(response); + setSelectedVariant(response.variants?.[0] || null); + } catch (error) { + if (error instanceof Error) { + setError(error.message); + } + } finally { + setIsLoading(false); + } + }; + + fetchProduct(); + }, [params.productId]); + const formatPrice = (amount: number, currency: string) => { return new Intl.NumberFormat('da-DK', { style: 'currency', @@ -38,15 +70,51 @@ const ProductDetails: React.FC = ({ product }) => { }; const handleAddToCart = () => { - // If product has variants, only add to cart if a variant is selected + if (!product) { + setError('Product not found'); + return; + } + if (product.variants.length > 0 && !selectedVariant) { - // You might want to add a toast or error message here + toast({ + title: 'Please select a size', + description: 'Please select a size before adding to cart', + variant: 'destructive' + }); return; } addToCart(product, selectedVariant); }; + if (error || !product) { + return ( +
+ + Error + + {error || 'Product not found'} + + + + + Back to Shop + +
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + return (
{/* Navigation */} @@ -61,10 +129,19 @@ const ProductDetails: React.FC = ({ product }) => { - Home - Products - {product.name} - + + + Home + + + + products + + + + {product.name} + +
@@ -136,8 +213,6 @@ const ProductDetails: React.FC = ({ product }) => {
-
+
); -}; - -export default ProductDetails; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index cc2498f..1d50aa8 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -21,7 +21,7 @@ const AuthContext = createContext<{ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(false); const { toast } = useToast(); const router = useRouter(); diff --git a/src/services/authService.ts b/src/services/authService.ts index 9a99479..986cb45 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -6,7 +6,7 @@ class AuthService extends BaseApiService { private static instance: AuthService; private constructor() { - super('http://localhost:6091/api/v1/auth'); + super('http://localhost:6091/api/v1/auth', '/auth'); } public static getInstance(): AuthService { @@ -20,8 +20,6 @@ class AuthService extends BaseApiService { try { const response = await this.post('/signup', data); - console.log(response); - if (response.token.length >= 0) { authStorage.setToken(response.token); } diff --git a/src/types/apiBase.ts b/src/types/apiBase.ts index e226168..031166e 100644 --- a/src/types/apiBase.ts +++ b/src/types/apiBase.ts @@ -20,8 +20,29 @@ interface RequestOptions { export abstract class BaseApiService { protected constructor( - protected baseUrl: string - ) { } + protected baseUrl: string, + protected endpoint: string = '' + ) { + if (process.env.NODE_ENV === 'development') { + this.baseUrl = process.env.NEXT_PUBLIC_DEV_COMMERCIFY_API_URL as string; + } else { + this.baseUrl = process.env.NEXT_PUBLIC_COMMERCIFY_API_URL as string; + } + + if (this.baseUrl.endsWith('/')) { + this.baseUrl = this.baseUrl.slice(0, -1); + } + + if (endpoint.length > 0) { + if (endpoint.startsWith('/')) { + this.endpoint = endpoint; + } else { + this.endpoint = `/${endpoint}`; + } + + this.baseUrl += this.endpoint; + } + } protected getHeaders(options: RequestOptions = {}): HeadersInit { const { requiresAuth = false, includeContentType = true } = options;