feat: initial implementation of modern modular webshop (shop core)

This commit is contained in:
DanielS
2026-04-30 23:41:56 +02:00
commit 5538d87283
72 changed files with 12853 additions and 0 deletions

57
shop/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,57 @@
import Link from 'next/link'
import { LayoutDashboard, Package, Settings, Users, LogOut } from 'lucide-react'
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex min-h-screen bg-[#020617] text-white">
{/* Sidebar */}
<aside className="w-64 border-r border-white/5 bg-black/20 backdrop-blur-xl flex flex-col">
<div className="p-6">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<Package className="h-5 w-5 text-white" />
</div>
<span className="font-bold text-xl tracking-tighter">Admin Panel</span>
</Link>
</div>
<nav className="flex-1 px-4 space-y-2 py-4">
<Link href="/admin" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<LayoutDashboard className="w-5 h-5" />
Dashboard
</Link>
<Link href="/admin/products" className="flex items-center gap-3 px-3 py-2 rounded-lg bg-primary/10 text-primary border border-primary/20">
<Package className="w-5 h-5" />
Produkte
</Link>
<Link href="/admin/orders" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Users className="w-5 h-5" />
Bestellungen
</Link>
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Settings className="w-5 h-5" />
Einstellungen
</Link>
</nav>
<div className="p-4 border-t border-white/5">
<button className="flex w-full items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<LogOut className="w-5 h-5" />
Abmelden
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-auto">
<div className="container mx-auto max-w-7xl">
{children}
</div>
</main>
</div>
)
}

87
shop/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,87 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
export default function AdminDashboard() {
return (
<div className="p-8 space-y-8">
<h2 className="text-3xl font-bold tracking-tight text-gradient">Dashboard</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Umsatz gesamt</CardTitle>
<TrendingUp className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">12,234.56</div>
<p className="text-xs text-muted-foreground">+20.1% zum Vormonat</p>
</CardContent>
</Card>
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Bestellungen</CardTitle>
<ShoppingCart className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+573</div>
<p className="text-xs text-muted-foreground">+12% zur Vorwoche</p>
</CardContent>
</Card>
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Produkte</CardTitle>
<Package className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">12</div>
<p className="text-xs text-muted-foreground">+2 neue Module</p>
</CardContent>
</Card>
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Kunden</CardTitle>
<Users className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">2,450</div>
<p className="text-xs text-muted-foreground">+180 heute</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4 glass-dark border-white/5">
<CardHeader>
<CardTitle>Übersicht</CardTitle>
</CardHeader>
<CardContent className="pl-2">
<div className="h-[200px] flex items-center justify-center text-muted-foreground">
[Chart Platzhalter]
</div>
</CardContent>
</Card>
<Card className="col-span-3 glass-dark border-white/5">
<CardHeader>
<CardTitle>Letzte Bestellungen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-4">
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold">
JD
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">John Doe</p>
<p className="text-xs text-muted-foreground">john.doe@example.com</p>
</div>
<div className="font-medium">+450.00</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { getProducts } from '@/lib/actions/products'
import { ProductList } from '@/components/admin/product-list'
import { CreateProductDialog } from '@/components/admin/create-product-dialog'
import { Package, Plus } from 'lucide-react'
import { Suspense } from 'react'
export default async function AdminProductsPage() {
return (
<div className="flex-1 space-y-8 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
<Package className="w-8 h-8 text-primary" />
Produkte & Module
</h2>
<p className="text-muted-foreground">
Verwalte deine Hauptprodukte und deren zubuchbare Module.
</p>
</div>
<div className="flex items-center space-x-2">
<CreateProductDialog>
<button className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50">
<Plus className="mr-2 h-4 w-4" /> Produkt hinzufügen
</button>
</CreateProductDialog>
</div>
</div>
<Suspense fallback={<div className="h-40 w-full animate-pulse bg-white/5 rounded-xl" />}>
<ProductDataWrapper />
</Suspense>
</div>
)
}
async function ProductDataWrapper() {
const products = await getProducts()
return <ProductList initialProducts={products} />
}

View File

@@ -0,0 +1,30 @@
import { createClient } from "@/lib/supabase/server";
import { type EmailOtpType } from "@supabase/supabase-js";
import { redirect } from "next/navigation";
import { type NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const next = searchParams.get("next") ?? "/";
if (token_hash && type) {
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
// redirect user to specified redirect URL or root of app
redirect(next);
} else {
// redirect the user to an error page with some instructions
redirect(`/auth/error?error=${error?.message}`);
}
}
// redirect the user to an error page with some instructions
redirect(`/auth/error?error=No token hash or type`);
}

View File

@@ -0,0 +1,51 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Suspense } from "react";
async function ErrorContent({
searchParams,
}: {
searchParams: Promise<{ error: string }>;
}) {
const params = await searchParams;
return (
<>
{params?.error ? (
<p className="text-sm text-muted-foreground">
Code error: {params.error}
</p>
) : (
<p className="text-sm text-muted-foreground">
An unspecified error occurred.
</p>
)}
</>
);
}
export default function Page({
searchParams,
}: {
searchParams: Promise<{ error: string }>;
}) {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle className="text-2xl">
Sorry, something went wrong.
</CardTitle>
</CardHeader>
<CardContent>
<Suspense>
<ErrorContent searchParams={searchParams} />
</Suspense>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { ForgotPasswordForm } from "@/components/forgot-password-form";
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<ForgotPasswordForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { LoginForm } from "@/components/login-form";
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle className="text-2xl">
Thank you for signing up!
</CardTitle>
<CardDescription>Check your email to confirm</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
You&apos;ve successfully signed up. Please check your email to
confirm your account before signing in.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { SignUpForm } from "@/components/sign-up-form";
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<SignUpForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { UpdatePasswordForm } from "@/components/update-password-form";
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<UpdatePasswordForm />
</div>
</div>
);
}

BIN
shop/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

72
shop/app/globals.css Normal file
View File

@@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 220 33% 98%;
--foreground: 220 40% 2%;
--card: 0 0% 100%;
--card-foreground: 220 40% 2%;
--popover: 0 0% 100%;
--popover-foreground: 220 40% 2%;
--primary: 220 90% 56%;
--primary-foreground: 0 0% 100%;
--secondary: 220 14% 90%;
--secondary-foreground: 220 40% 2%;
--muted: 220 14% 90%;
--muted-foreground: 220 14% 40%;
--accent: 220 14% 90%;
--accent-foreground: 220 40% 2%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 14% 85%;
--input: 220 14% 85%;
--ring: 220 90% 56%;
--radius: 0.75rem;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--popover: 224 71% 4%;
--popover-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--muted: 222.2 47.4% 11.2%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 222.2 47.4% 11.2%;
--accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--border: 222.2 47.4% 11.2%;
--input: 222.2 47.4% 11.2%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
@layer utilities {
.glass {
@apply bg-white/10 backdrop-blur-md border border-white/20;
}
.glass-dark {
@apply bg-black/20 backdrop-blur-md border border-white/10;
}
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400;
}
}

41
shop/app/layout.tsx Normal file
View File

@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { ThemeProvider } from "next-themes";
import "./globals.css";
const defaultUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
export const metadata: Metadata = {
metadataBase: new URL(defaultUrl),
title: "CloudShop | Modulare Software-Lösungen",
description: "Konfigurieren und bestellen Sie Ihre maßgeschneiderte Business-Software in Minuten.",
};
const geistSans = Geist({
variable: "--font-geist-sans",
display: "swap",
subsets: ["latin"],
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.className} antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

46
shop/app/order/page.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { createClient } from '@/lib/supabase/server'
import { getProducts } from '@/lib/actions/products'
import { OrderWizard } from '@/components/order-wizard'
import { redirect } from 'next/navigation'
import { Suspense } from 'react'
export default async function OrderPage() {
return (
<div className="min-h-screen bg-[#0a0a0a] text-white selection:bg-primary/30">
<div className="container mx-auto py-10">
<div className="text-center mb-12">
<h1 className="text-5xl font-extrabold tracking-tight mb-4 text-gradient">
Konfigurieren Sie Ihre Lösung
</h1>
<p className="text-slate-400 text-lg max-w-2xl mx-auto">
Wählen Sie das passende Paket und die benötigten Module für Ihr Business.
</p>
</div>
<Suspense fallback={<div className="h-96 w-full animate-pulse bg-white/5 rounded-2xl" />}>
<OrderDataWrapper />
</Suspense>
</div>
</div>
)
}
async function OrderDataWrapper() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/auth/login')
}
const products = await getProducts()
// Fetch profile if exists
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single()
return <OrderWizard products={products} initialProfile={profile} />
}

96
shop/app/page.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { Rocket, Shield, Cpu, ArrowRight } from 'lucide-react'
export default function Home() {
return (
<div className="flex flex-col min-h-screen bg-[#020617] text-white">
<header className="px-4 lg:px-6 h-16 flex items-center border-b border-white/5 backdrop-blur-md sticky top-0 z-50">
<Link className="flex items-center justify-center gap-2" href="#">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<Rocket className="h-5 w-5 text-white" />
</div>
<span className="font-bold text-xl tracking-tighter">CloudShop</span>
</Link>
<nav className="ml-auto flex gap-4 sm:gap-6 items-center">
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/order">
Bestellen
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/admin/products">
Admin
</Link>
<Button variant="outline" className="border-white/10 hover:bg-white/5">
Login
</Button>
</nav>
</header>
<main className="flex-1">
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48 px-4 overflow-hidden relative">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-primary/20 blur-[120px] rounded-full -z-10 opacity-30" />
<div className="container mx-auto">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<div className="inline-block rounded-full bg-primary/10 px-3 py-1 text-sm text-primary font-medium border border-primary/20 mb-4">
Version 2.0 ist da
</div>
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
Die Zukunft der <span className="text-gradient">Software-Bestellung</span>
</h1>
<p className="mx-auto max-w-[700px] text-slate-400 md:text-xl dark:text-zinc-400 mt-6">
Modulare Lösungen, blitzschnelles Deployment und ein intuitives Interface für Ihre Business-Anforderungen.
</p>
</div>
<div className="space-x-4 mt-8">
<Link href="/order">
<Button size="lg" className="h-12 px-8 text-lg font-semibold shadow-lg shadow-primary/20 transition-all hover:scale-105">
Jetzt konfigurieren <ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link href="/admin/products">
<Button size="lg" variant="outline" className="h-12 px-8 text-lg border-white/10 hover:bg-white/5">
Admin Bereich
</Button>
</Link>
</div>
</div>
</div>
</section>
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-white/5 bg-white/[0.02]">
<div className="container mx-auto px-4 md:px-6">
<div className="grid gap-12 lg:grid-cols-3">
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Cpu className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Modulare Architektur</h3>
<p className="text-slate-400">Wählen Sie genau die Module, die Sie benötigen. Keine unnötigen Kosten für Features, die Sie nicht nutzen.</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Shield className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Maximale Sicherheit</h3>
<p className="text-slate-400">Enterprise-grade Security mit Supabase Auth und granularen Row Level Security Policies.</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Rocket className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Sofort Einsatzbereit</h3>
<p className="text-slate-400">Nach der Bestellung wird Ihre Instanz automatisch provisioniert und ist in Minuten online.</p>
</div>
</div>
</div>
</section>
</main>
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t border-white/5 bg-[#020617]">
<p className="text-xs text-slate-500">© 2026 CloudShop GmbH. Alle Rechte vorbehalten.</p>
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Impressum</Link>
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Datenschutz</Link>
</nav>
</footer>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { DeployButton } from "@/components/deploy-button";
import { EnvVarWarning } from "@/components/env-var-warning";
import { AuthButton } from "@/components/auth-button";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { hasEnvVars } from "@/lib/utils";
import Link from "next/link";
import { Suspense } from "react";
export default function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="min-h-screen flex flex-col items-center">
<div className="flex-1 w-full flex flex-col gap-20 items-center">
<nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
<div className="w-full max-w-5xl flex justify-between items-center p-3 px-5 text-sm">
<div className="flex gap-5 items-center font-semibold">
<Link href={"/"}>Next.js Supabase Starter</Link>
<div className="flex items-center gap-2">
<DeployButton />
</div>
</div>
{!hasEnvVars ? (
<EnvVarWarning />
) : (
<Suspense>
<AuthButton />
</Suspense>
)}
</div>
</nav>
<div className="flex-1 flex flex-col gap-20 max-w-5xl p-5">
{children}
</div>
<footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16">
<p>
Powered by{" "}
<a
href="https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs"
target="_blank"
className="font-bold hover:underline"
rel="noreferrer"
>
Supabase
</a>
</p>
<ThemeSwitcher />
</footer>
</div>
</main>
);
}

View File

@@ -0,0 +1,43 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { InfoIcon } from "lucide-react";
import { FetchDataSteps } from "@/components/tutorial/fetch-data-steps";
import { Suspense } from "react";
async function UserDetails() {
const supabase = await createClient();
const { data, error } = await supabase.auth.getClaims();
if (error || !data?.claims) {
redirect("/auth/login");
}
return JSON.stringify(data.claims, null, 2);
}
export default function ProtectedPage() {
return (
<div className="flex-1 w-full flex flex-col gap-12">
<div className="w-full">
<div className="bg-accent text-sm p-3 px-5 rounded-md text-foreground flex gap-3 items-center">
<InfoIcon size="16" strokeWidth={2} />
This is a protected page that you can only see as an authenticated
user
</div>
</div>
<div className="flex flex-col gap-2 items-start">
<h2 className="font-bold text-2xl mb-4">Your user details</h2>
<pre className="text-xs font-mono p-3 rounded border max-h-32 overflow-auto">
<Suspense>
<UserDetails />
</Suspense>
</pre>
</div>
<div>
<h2 className="font-bold text-2xl mb-4">Next steps</h2>
<FetchDataSteps />
</div>
</div>
);
}

BIN
shop/app/twitter-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB