diff --git a/README.md b/README.md index f06471a..4a84606 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,8 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# ASSCO -## Getting Started - -First, run the development server: +# Build ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +npm run build +npm start ``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts new file mode 100644 index 0000000..2836b7c --- /dev/null +++ b/src/app/api/webhook/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + + if (!webhookUrl) { + console.error("DISCORD_WEBHOOK_URL environment variable not set"); + return NextResponse.json( + { error: "Webhook not configured" }, + { status: 500 }, + ); + } + + const response = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Discord API responded with status ${response.status}`); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Webhook error:", error); + return NextResponse.json( + { error: "Failed to send webhook" }, + { status: 500 }, + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index f1d8c73..0f5024c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1 +1,42 @@ @import "tailwindcss"; + +body { + background-color: #111827; + color: #ffffff; + font-family: system-ui, -apple-system, sans-serif; +} + +.container { + max-width: 1200px; +} + +input:focus, +select:focus { + outline: none; + border-color: #22c55e; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.text-green-400 { + color: #4ade80; +} + +.bg-green-600 { + background-color: #16a34a; +} + +.bg-green-700 { + background-color: #15803d; +} + +.bg-green-800 { + background-color: #166534; +} + +.hover\:bg-green-700:hover { + background-color: #15803d; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 756fcce..5a85446 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,8 +2,8 @@ import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "ASS AB - Allt vi Skapar Säljer vi", + description: "Beställ Minecraft-föremål från ASS AB", }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index b95c3ed..ab1c8f5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,107 @@ -export default function Home() { +"use client"; + +import { CartProvider, useCart } from "../contexts/CartContext"; +import ProductSelection from "../components/ProductSelection"; +import CartView from "../components/CartView"; +import DeliveryDetails from "../components/DeliveryDetails"; + +function OrderApp() { + const { state, dispatch } = useCart(); + + const steps = ["Lägg till produkter", "Kundvagn", "Leveransuppgifter"]; + + const nextStep = () => { + if (state.currentStep < 2) { + dispatch({ type: "SET_STEP", payload: state.currentStep + 1 }); + } + }; + + const prevStep = () => { + if (state.currentStep > 0) { + dispatch({ type: "SET_STEP", payload: state.currentStep - 1 }); + } + }; + + const canProceed = () => { + if (state.currentStep === 0) return state.items.length > 0; + if (state.currentStep === 1) return state.items.length > 0; + return true; + }; + return ( -
-
Hello world!
-
+
+
+
+

+ ASS AB - Allt vi Skapar Säljer vi +

+
+
+ +
+
+
+ {steps.map((step, index) => ( +
+
+ {step} +
+ {index < steps.length - 1 && ( +
+ )} +
+ ))} +
+
+ +
+ {state.currentStep === 0 && } + {state.currentStep === 1 && } + {state.currentStep === 2 && } + +
+ + + {state.currentStep < 2 && ( + + )} +
+
+
+ + +
+ ); +} + +export default function Home() { + return ( + + + ); } diff --git a/src/components/CartView.tsx b/src/components/CartView.tsx new file mode 100644 index 0000000..6ad29af --- /dev/null +++ b/src/components/CartView.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import { useCart } from "../contexts/CartContext"; +import { formatDiamonds } from "../utils/formatDiamonds"; +import discountCodes from "../data/discountCodes.json"; + +export default function CartView() { + const { state, dispatch } = useCart(); + const [discountCode, setDiscountCode] = useState(""); + + const calculateItemTotal = (item: (typeof state.items)[0]) => { + let basePrice = item.product.pricePerStack * item.quantity; + + if (item.product.id === "cobblestone" && item.variant !== "Cobblestone") { + basePrice = item.quantity / 8; + } + + if (item.deliveryOption === "shulker") { + basePrice += 1; + } + + return Math.round(basePrice); + }; + + const subtotal = state.items.reduce( + (sum, item) => sum + calculateItemTotal(item), + 0, + ); + const discount = Math.round(subtotal * (state.discountPercentage / 100)); + const total = subtotal - discount; + + const applyDiscountCode = () => { + const code = discountCode.toLowerCase(); + const discountInfo = discountCodes[code as keyof typeof discountCodes]; + + if (discountInfo) { + dispatch({ + type: "SET_DISCOUNT", + payload: { code: discountCode, percentage: discountInfo.percentage }, + }); + } else { + dispatch({ type: "SET_DISCOUNT", payload: { code: "", percentage: 0 } }); + } + }; + + const removeItem = (index: number) => { + dispatch({ type: "REMOVE_ITEM", payload: index }); + }; + + const updateQuantity = (index: number, quantity: number) => { + if (quantity <= 0) { + removeItem(index); + } else { + dispatch({ type: "UPDATE_QUANTITY", payload: { index, quantity } }); + } + }; + + return ( +
+

Kundvagn

+ + {state.items.length === 0 ? ( +

Kundvagnen är tom

+ ) : ( +
+ {state.items.map((item, index) => ( +
+
+
+

+ {item.product.name} + {item.variant && ` (${item.variant})`} +

+

+ Leverans:{" "} + {item.deliveryOption === "shulker" + ? "Shulker (+1 Dia)" + : item.deliveryOption === "pickup" + ? "Pickup (-10%)" + : "Kistor"} +

+
+ Antal: + + updateQuantity(index, parseInt(e.target.value) || 0) + } + min="0" + className="w-20 bg-gray-600 text-white p-1 rounded border border-gray-500" + /> +
+
+
+

+ {formatDiamonds(calculateItemTotal(item))} +

+ +
+
+
+ ))} + +
+
+ Subtotal: + {formatDiamonds(subtotal)} +
+ + {state.discountPercentage > 0 && ( +
+ Rabatt ({state.discountCode}): + + -{formatDiamonds(discount)} ({state.discountPercentage}%) + +
+ )} + +
+ Totalt: + {formatDiamonds(total)} +
+
+ +
+ +
+ setDiscountCode(e.target.value)} + placeholder="Skriv rabattkod" + className="flex-1 bg-gray-700 text-white p-3 rounded border border-gray-600 focus:border-green-400 focus:outline-none" + /> + +
+
+
+ )} +
+ ); +} diff --git a/src/components/DeliveryDetails.tsx b/src/components/DeliveryDetails.tsx new file mode 100644 index 0000000..1664705 --- /dev/null +++ b/src/components/DeliveryDetails.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useState } from "react"; +import { useCart } from "../contexts/CartContext"; +import { formatDiamonds } from "../utils/formatDiamonds"; + +export default function DeliveryDetails() { + const { state, dispatch } = useCart(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const calculateTotal = () => { + let total = 0; + + state.items.forEach((item) => { + let itemPrice = item.product.pricePerStack * item.quantity; + + if (item.product.id === "cobblestone" && item.variant !== "Cobblestone") { + itemPrice = item.quantity / 8; + } + + if (item.deliveryOption === "shulker") { + itemPrice += 1; + } + + total += Math.round(itemPrice); + }); + + const hasPickup = state.items.some( + (item) => item.deliveryOption === "pickup", + ); + if (hasPickup) { + total = Math.round(total * 0.9); + } + + if (state.discountPercentage > 0) { + total = Math.round(total * (1 - state.discountPercentage / 100)); + } + + return total; + }; + + const submitOrder = async () => { + if (!state.playerName || (!hasPickup && !state.coords)) { + alert("Vänligen fyll i alla fält"); + return; + } + + setIsSubmitting(true); + + const finalCoords = hasPickup ? "x:2100 z:1500" : state.coords; + + const orderData = { + embeds: [ + { + title: "🛒 Ny beställning från ASS AB", + color: 0x22c55e, + fields: [ + { + name: "👤 Spelare", + value: state.playerName, + inline: true, + }, + { + name: "📍 Koordinater", + value: finalCoords, + inline: true, + }, + { + name: "💎 Total kostnad", + value: formatDiamonds(calculateTotal()), + inline: true, + }, + { + name: "📦 Beställda varor", + value: state.items + .map((item) => { + const itemTotal = (() => { + let price = item.product.pricePerStack * item.quantity; + if ( + item.product.id === "cobblestone" && + item.variant !== "Cobblestone" + ) { + price = item.quantity / 8; + } + if (item.deliveryOption === "shulker") { + price += 1; + } + return Math.round(price); + })(); + + return `• ${item.product.name}${item.variant ? ` (${item.variant})` : ""}: ${item.quantity} stacks - ${formatDiamonds(itemTotal)}\n Leverans: ${item.deliveryOption === "shulker" ? "Shulker (+1 Dia)" : item.deliveryOption === "pickup" ? "Pickup (-10%)" : "Kistor"}`; + }) + .join("\n"), + inline: false, + }, + ], + footer: { + text: `Beställning skickad ${new Date().toLocaleString("sv-SE")}`, + }, + }, + ], + }; + + try { + const response = await fetch("/api/webhook", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(orderData), + }); + + if (response.ok) { + alert("Beställning skickad! Vi kontaktar dig snart."); + dispatch({ type: "CLEAR_CART" }); + dispatch({ type: "SET_STEP", payload: 0 }); + dispatch({ type: "SET_PLAYER_NAME", payload: "" }); + dispatch({ type: "SET_COORDS", payload: "" }); + } else { + alert("Något gick fel. Försök igen."); + } + } catch (error) { + alert("Något gick fel. Försök igen."); + console.error("Error:", error); + } finally { + setIsSubmitting(false); + } + }; + + const hasPickup = state.items.some( + (item) => item.deliveryOption === "pickup", + ); + + return ( +
+

+ Leveransuppgifter +

+ +
+
+ + + dispatch({ type: "SET_PLAYER_NAME", payload: e.target.value }) + } + placeholder="Spelarnamn" + className="w-full bg-gray-700 text-white p-3 rounded border border-gray-600 focus:border-green-400 focus:outline-none" + /> +
+ +
+ + + !hasPickup && + dispatch({ type: "SET_COORDS", payload: e.target.value }) + } + placeholder={hasPickup ? "x:2100 z:1500" : "Ex: 123 64 -456"} + disabled={hasPickup} + className={`w-full bg-gray-700 text-white p-3 rounded border border-gray-600 focus:border-green-400 focus:outline-none ${hasPickup ? "opacity-75" : ""}`} + /> +
+ +
+

Ordersammanfattning

+
+ {state.items.map((item, index) => ( +
+ + {item.product.name} + {item.variant ? ` (${item.variant})` : ""} x{item.quantity} + + + {(() => { + let price = item.product.pricePerStack * item.quantity; + if ( + item.product.id === "cobblestone" && + item.variant !== "Cobblestone" + ) { + price = item.quantity / 8; + } + if (item.deliveryOption === "shulker") { + price += 1; + } + return formatDiamonds(Math.round(price)); + })()} + +
+ ))} +
+
+ Total: + {formatDiamonds(calculateTotal())} +
+
+
+
+ + +
+
+ ); +} diff --git a/src/components/ProductSelection.tsx b/src/components/ProductSelection.tsx new file mode 100644 index 0000000..c417695 --- /dev/null +++ b/src/components/ProductSelection.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState } from "react"; +import { products } from "../data/products"; +import { useCart } from "../contexts/CartContext"; +import type { Product, CartItem } from "../types/product"; + +export default function ProductSelection() { + const { dispatch } = useCart(); + const [selectedProduct, setSelectedProduct] = useState(null); + const [quantity, setQuantity] = useState(1); + const [deliveryOption, setDeliveryOption] = useState< + "shulker" | "chests" | "pickup" + >("chests"); + const [variant, setVariant] = useState(""); + + const handleAddToCart = () => { + if (!selectedProduct) return; + + const item: CartItem = { + product: selectedProduct, + quantity, + unit: "stacks", + deliveryOption, + variant: variant || undefined, + }; + + dispatch({ type: "ADD_ITEM", payload: item }); + + setSelectedProduct(null); + setQuantity(1); + setVariant(""); + }; + + const getProductPrice = (product: Product) => { + if (product.defaultStacksPerDia) { + return `${product.defaultStacksPerDia} Stacks = 1 Dia`; + } + return `1 Stack = ${product.pricePerStack} Dia`; + }; + + return ( +
+

+ Lägg till produkt +

+ +
+
+ + +
+ + {selectedProduct?.variants && ( +
+ + +
+ )} + +
+ + + setQuantity(Math.max(1, parseInt(e.target.value) || 1)) + } + min="1" + className="w-full bg-gray-700 text-white p-3 rounded border border-gray-600 focus:border-green-400 focus:outline-none" + /> +
+ +
+ + +
+ + +
+
+ ); +} diff --git a/src/contexts/CartContext.tsx b/src/contexts/CartContext.tsx new file mode 100644 index 0000000..bcee3e2 --- /dev/null +++ b/src/contexts/CartContext.tsx @@ -0,0 +1,117 @@ +"use client"; + +import React, { createContext, useContext, useReducer, ReactNode } from "react"; +import type { CartItem } from "../types/product"; + +interface CartState { + items: CartItem[]; + discountCode: string; + discountPercentage: number; + playerName: string; + coords: string; + currentStep: number; +} + +type CartAction = + | { type: "ADD_ITEM"; payload: CartItem } + | { type: "REMOVE_ITEM"; payload: number } + | { type: "UPDATE_QUANTITY"; payload: { index: number; quantity: number } } + | { type: "CLEAR_CART" } + | { type: "SET_DISCOUNT"; payload: { code: string; percentage: number } } + | { type: "SET_PLAYER_NAME"; payload: string } + | { type: "SET_COORDS"; payload: string } + | { type: "SET_STEP"; payload: number }; + +const initialState: CartState = { + items: [], + discountCode: "", + discountPercentage: 0, + playerName: "", + coords: "", + currentStep: 0, +}; + +function cartReducer(state: CartState, action: CartAction): CartState { + switch (action.type) { + case "ADD_ITEM": + const existingIndex = state.items.findIndex( + (item) => + item.product.id === action.payload.product.id && + item.variant === action.payload.variant && + item.unit === action.payload.unit && + item.deliveryOption === action.payload.deliveryOption, + ); + + if (existingIndex >= 0) { + const newItems = [...state.items]; + newItems[existingIndex] = { + ...newItems[existingIndex], + quantity: newItems[existingIndex].quantity + action.payload.quantity, + }; + return { ...state, items: newItems }; + } + + return { ...state, items: [...state.items, action.payload] }; + + case "REMOVE_ITEM": + return { + ...state, + items: state.items.filter((_, index) => index !== action.payload), + }; + + case "UPDATE_QUANTITY": + const updatedItems = [...state.items]; + if (updatedItems[action.payload.index]) { + updatedItems[action.payload.index] = { + ...updatedItems[action.payload.index], + quantity: action.payload.quantity, + }; + } + return { ...state, items: updatedItems }; + + case "CLEAR_CART": + return { ...state, items: [] }; + + case "SET_DISCOUNT": + return { + ...state, + discountCode: action.payload.code, + discountPercentage: action.payload.percentage, + }; + + case "SET_PLAYER_NAME": + return { ...state, playerName: action.payload }; + + case "SET_COORDS": + return { ...state, coords: action.payload }; + + case "SET_STEP": + return { ...state, currentStep: action.payload }; + + default: + return state; + } +} + +const CartContext = createContext<{ + state: CartState; + dispatch: React.Dispatch; +} | null>(null); + +export function CartProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(cartReducer, initialState); + + return ( + + {children} + + ); +} + +export function useCart() { + const context = useContext(CartContext); + if (!context) { + throw new Error("useCart must be used within a CartProvider"); + } + return context; +} diff --git a/src/data/discountCodes.json b/src/data/discountCodes.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/data/discountCodes.json @@ -0,0 +1 @@ +{} diff --git a/src/data/products.ts b/src/data/products.ts new file mode 100644 index 0000000..9b793ef --- /dev/null +++ b/src/data/products.ts @@ -0,0 +1,42 @@ +import type { Product } from "../types/product"; + +export const products: Product[] = [ + { + id: "spruce-log", + name: "Spruce Log", + pricePerStack: 1, + }, + { + id: "birch-log", + name: "Birch Log", + pricePerStack: 1, + }, + { + id: "oak-log", + name: "Oak Log", + pricePerStack: 1, + }, + { + id: "jungle-log", + name: "Jungle Log", + pricePerStack: 1, + }, + { + id: "cobblestone", + name: "Cobblestone", + pricePerStack: 1 / 16, + defaultStacksPerDia: 16, + variants: ["Cobblestone", "Stone", "Stone Bricks"], + }, + { + id: "sand", + name: "Sand", + pricePerStack: 1, + }, + { + id: "gunpowder", + name: "Gunpowder", + pricePerStack: 1 / 4, + defaultStacksPerDia: 4, + }, +]; diff --git a/src/types/product.ts b/src/types/product.ts new file mode 100644 index 0000000..5a0912e --- /dev/null +++ b/src/types/product.ts @@ -0,0 +1,65 @@ +export interface Product { + id: string; + name: string; + pricePerStack: number; + variants?: string[]; + defaultStacksPerDia?: number; +} + +export interface CartItem { + product: Product; + quantity: number; + variant?: string; + unit: "stacks" | "shulkers" | "double-chests"; + deliveryOption: "shulker" | "chests" | "pickup"; +} + +export interface Order { + items: CartItem[]; + total: number; + discount: number; + discountCode?: string; + playerName: string; + coords: string; +} + +export const products: Product[] = [ + { + id: "spruce-log", + name: "Spruce Log", + pricePerStack: 1, + }, + { + id: "birch-log", + name: "Birch Log", + pricePerStack: 1, + }, + { + id: "oak-log", + name: "Oak Log", + pricePerStack: 1, + }, + { + id: "jungle-log", + name: "Jungle Log", + pricePerStack: 1, + }, + { + id: "cobblestone", + name: "Cobblestone", + pricePerStack: 1 / 16, + defaultStacksPerDia: 16, + variants: ["Cobblestone", "Stone", "Stone Bricks"], + }, + { + id: "sand", + name: "Sand", + pricePerStack: 1, + }, + { + id: "gunpowder", + name: "Gunpowder", + pricePerStack: 1 / 4, + defaultStacksPerDia: 4, + }, +]; diff --git a/src/utils/formatDiamonds.ts b/src/utils/formatDiamonds.ts new file mode 100644 index 0000000..510205c --- /dev/null +++ b/src/utils/formatDiamonds.ts @@ -0,0 +1,16 @@ +export function formatDiamonds(totalDia: number): string { + const roundedDia = Math.round(totalDia); + + if (roundedDia < 64) { + return `${roundedDia} Dia`; + } + + const stacks = Math.floor(roundedDia / 64); + const remaining = roundedDia % 64; + + if (remaining === 0) { + return `${stacks} ${stacks === 1 ? "stack" : "stacks"} Dia`; + } + + return `${stacks} ${stacks === 1 ? "stack" : "stacks"} + ${remaining} Dia`; +}