balls
This commit is contained in:
parent
34cd43332b
commit
4199f50bac
34
README.md
34
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
|
# Build
|
||||||
|
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run build
|
||||||
# or
|
npm start
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
37
src/app/api/webhook/route.ts
Normal file
37
src/app/api/webhook/route.ts
Normal file
@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,42 @@
|
|||||||
@import "tailwindcss";
|
@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;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "ASS AB - Allt vi Skapar Säljer vi",
|
||||||
description: "Generated by create next app",
|
description: "Beställ Minecraft-föremål från ASS AB",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
108
src/app/page.tsx
108
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 (
|
return (
|
||||||
<main>
|
<div className="min-h-screen bg-gray-900">
|
||||||
<div>Hello world!</div>
|
<header className="bg-green-700 text-white py-6">
|
||||||
</main>
|
<div className="container mx-auto px-4">
|
||||||
|
<h1 className="text-3xl font-bold text-center">
|
||||||
|
ASS AB - Allt vi Skapar Säljer vi
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={index} className="flex items-center">
|
||||||
|
<div
|
||||||
|
className={`px-4 py-2 rounded-full ${
|
||||||
|
index === state.currentStep
|
||||||
|
? "bg-green-600 text-white"
|
||||||
|
: index < state.currentStep
|
||||||
|
? "bg-green-800 text-white"
|
||||||
|
: "bg-gray-600 text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</div>
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div className="w-8 h-0.5 bg-gray-600 mx-2"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{state.currentStep === 0 && <ProductSelection />}
|
||||||
|
{state.currentStep === 1 && <CartView />}
|
||||||
|
{state.currentStep === 2 && <DeliveryDetails />}
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-8">
|
||||||
|
<button
|
||||||
|
onClick={prevStep}
|
||||||
|
disabled={state.currentStep === 0}
|
||||||
|
className="px-6 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-800 disabled:cursor-not-allowed text-white rounded transition-colors"
|
||||||
|
>
|
||||||
|
Tillbaka
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{state.currentStep < 2 && (
|
||||||
|
<button
|
||||||
|
onClick={nextStep}
|
||||||
|
disabled={!canProceed()}
|
||||||
|
className="px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded transition-colors"
|
||||||
|
>
|
||||||
|
Nästa
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="bg-gray-800 text-gray-400 py-6 mt-12">
|
||||||
|
<div className="container mx-auto px-4 text-center">
|
||||||
|
<p>© ASS co 2025 - Allt vi Skapar Säljer vi</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<CartProvider>
|
||||||
|
<OrderApp />
|
||||||
|
</CartProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
157
src/components/CartView.tsx
Normal file
157
src/components/CartView.tsx
Normal file
@ -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 (
|
||||||
|
<div className="bg-gray-800 p-6 rounded-lg border border-gray-700">
|
||||||
|
<h2 className="text-2xl font-bold text-green-400 mb-6">Kundvagn</h2>
|
||||||
|
|
||||||
|
{state.items.length === 0 ? (
|
||||||
|
<p className="text-gray-400">Kundvagnen är tom</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{state.items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-gray-700 p-4 rounded border border-gray-600"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-white font-semibold">
|
||||||
|
{item.product.name}
|
||||||
|
{item.variant && ` (${item.variant})`}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Leverans:{" "}
|
||||||
|
{item.deliveryOption === "shulker"
|
||||||
|
? "Shulker (+1 Dia)"
|
||||||
|
: item.deliveryOption === "pickup"
|
||||||
|
? "Pickup (-10%)"
|
||||||
|
: "Kistor"}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="text-gray-300">Antal:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateQuantity(index, parseInt(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
min="0"
|
||||||
|
className="w-20 bg-gray-600 text-white p-1 rounded border border-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-white font-semibold">
|
||||||
|
{formatDiamonds(calculateItemTotal(item))}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
className="text-red-400 hover:text-red-300 mt-2 text-sm"
|
||||||
|
>
|
||||||
|
Ta bort
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="border-t border-gray-600 pt-4 space-y-2">
|
||||||
|
<div className="flex justify-between text-gray-300">
|
||||||
|
<span>Subtotal:</span>
|
||||||
|
<span>{formatDiamonds(subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.discountPercentage > 0 && (
|
||||||
|
<div className="flex justify-between text-green-400">
|
||||||
|
<span>Rabatt ({state.discountCode}):</span>
|
||||||
|
<span>
|
||||||
|
-{formatDiamonds(discount)} ({state.discountPercentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between text-white font-bold text-lg border-t border-gray-600 pt-2">
|
||||||
|
<span>Totalt:</span>
|
||||||
|
<span>{formatDiamonds(total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-600 pt-4">
|
||||||
|
<label className="block text-gray-300 mb-2">Rabattkod:</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={discountCode}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={applyDiscountCode}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Använd kod
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/components/DeliveryDetails.tsx
Normal file
218
src/components/DeliveryDetails.tsx
Normal file
@ -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 (
|
||||||
|
<div className="bg-gray-800 p-6 rounded-lg border border-gray-700">
|
||||||
|
<h2 className="text-2xl font-bold text-green-400 mb-6">
|
||||||
|
Leveransuppgifter
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-300 mb-2">Namn:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={state.playerName}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-300 mb-2">
|
||||||
|
Koordinater {hasPickup && "(pickup på x:2100 z:1500)"}:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hasPickup ? "x:2100 z:1500" : state.coords}
|
||||||
|
onChange={(e) =>
|
||||||
|
!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" : ""}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-700 p-4 rounded border border-gray-600">
|
||||||
|
<h3 className="text-white font-semibold mb-3">Ordersammanfattning</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{state.items.map((item, index) => (
|
||||||
|
<div key={index} className="flex justify-between text-gray-300">
|
||||||
|
<span>
|
||||||
|
{item.product.name}
|
||||||
|
{item.variant ? ` (${item.variant})` : ""} x{item.quantity}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{(() => {
|
||||||
|
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));
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="border-t border-gray-600 pt-2 font-bold text-white">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span>{formatDiamonds(calculateTotal())}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={submitOrder}
|
||||||
|
disabled={
|
||||||
|
!state.playerName || (!hasPickup && !state.coords) || isSubmitting
|
||||||
|
}
|
||||||
|
className="w-full bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-semibold py-3 px-4 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Skickar beställning..." : "Beställ"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/ProductSelection.tsx
Normal file
126
src/components/ProductSelection.tsx
Normal file
@ -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<Product | null>(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 (
|
||||||
|
<div className="bg-gray-800 p-6 rounded-lg border border-gray-700">
|
||||||
|
<h2 className="text-2xl font-bold text-green-400 mb-6">
|
||||||
|
Lägg till produkt
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-300 mb-2">Produkt:</label>
|
||||||
|
<select
|
||||||
|
value={selectedProduct?.id || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const product = products.find((p) => p.id === e.target.value);
|
||||||
|
setSelectedProduct(product || null);
|
||||||
|
setVariant("");
|
||||||
|
}}
|
||||||
|
className="w-full bg-gray-700 text-white p-3 rounded border border-gray-600 focus:border-green-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
{products.map((product) => (
|
||||||
|
<option key={product.id} value={product.id}>
|
||||||
|
{product.name} ({getProductPrice(product)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedProduct?.variants && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-300 mb-2">Variant:</label>
|
||||||
|
<select
|
||||||
|
value={variant}
|
||||||
|
onChange={(e) => setVariant(e.target.value)}
|
||||||
|
className="w-full bg-gray-700 text-white p-3 rounded border border-gray-600 focus:border-green-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Välj variant</option>
|
||||||
|
{selectedProduct.variants.map((variant) => (
|
||||||
|
<option key={variant} value={variant}>
|
||||||
|
{variant}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-300 mb-2">Antal stackar:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-300 mb-2">Leveransbehållare:</label>
|
||||||
|
<select
|
||||||
|
value={deliveryOption}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeliveryOption(
|
||||||
|
e.target.value as "shulker" | "chests" | "pickup",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full bg-gray-700 text-white p-3 rounded border border-gray-600 focus:border-green-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="chests">Kistor (standard)</option>
|
||||||
|
<option value="shulker">Shulker (+1 Dia)</option>
|
||||||
|
<option value="pickup">Pickup (-10% rabatt)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
disabled={!selectedProduct || (selectedProduct.variants && !variant)}
|
||||||
|
className="w-full bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-semibold py-3 px-4 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Lägg till i kundvagn
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/contexts/CartContext.tsx
Normal file
117
src/contexts/CartContext.tsx
Normal file
@ -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<CartAction>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
export function CartProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, dispatch] = useReducer(cartReducer, initialState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CartContext.Provider value={{ state, dispatch }}>
|
||||||
|
{children}
|
||||||
|
</CartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCart() {
|
||||||
|
const context = useContext(CartContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCart must be used within a CartProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
1
src/data/discountCodes.json
Normal file
1
src/data/discountCodes.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
42
src/data/products.ts
Normal file
42
src/data/products.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
65
src/types/product.ts
Normal file
65
src/types/product.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
16
src/utils/formatDiamonds.ts
Normal file
16
src/utils/formatDiamonds.ts
Normal file
@ -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`;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user