commit 5538d8728360e73a5d28cd8df81959267aae55e0 Author: DanielS Date: Thu Apr 30 23:41:56 2026 +0200 feat: initial implementation of modern modular webshop (shop core) diff --git a/shop/.env.example b/shop/.env.example new file mode 100644 index 0000000..91cdb14 --- /dev/null +++ b/shop/.env.example @@ -0,0 +1,4 @@ +# Update these with your Supabase details from your project settings > API +# https://app.supabase.com/project/_/settings/api +NEXT_PUBLIC_SUPABASE_URL=your-project-url +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key diff --git a/shop/.gitignore b/shop/.gitignore new file mode 100644 index 0000000..f1631b0 --- /dev/null +++ b/shop/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/shop/README.md b/shop/README.md new file mode 100644 index 0000000..d417bff --- /dev/null +++ b/shop/README.md @@ -0,0 +1,109 @@ + + Next.js and Supabase Starter Kit - the fastest way to build apps with Next.js and Supabase +

Next.js and Supabase Starter Kit

+
+ +

+ The fastest way to build apps with Next.js and Supabase +

+ +

+ Features · + Demo · + Deploy to Vercel · + Clone and run locally · + Feedback and issues + More Examples +

+
+ +## Features + +- Works across the entire [Next.js](https://nextjs.org) stack + - App Router + - Pages Router + - Proxy + - Client + - Server + - It just works! +- supabase-ssr. A package to configure Supabase Auth to use cookies +- Password-based authentication block installed via the [Supabase UI Library](https://supabase.com/ui/docs/nextjs/password-based-auth) +- Styling with [Tailwind CSS](https://tailwindcss.com) +- Components with [shadcn/ui](https://ui.shadcn.com/) +- Optional deployment with [Supabase Vercel Integration and Vercel deploy](#deploy-your-own) + - Environment variables automatically assigned to Vercel project + +## Demo + +You can view a fully working demo at [demo-nextjs-with-supabase.vercel.app](https://demo-nextjs-with-supabase.vercel.app/). + +## Deploy to Vercel + +Vercel deployment will guide you through creating a Supabase account and project. + +After installation of the Supabase integration, all relevant environment variables will be assigned to the project so the deployment is fully functioning. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&project-name=nextjs-with-supabase&repository-name=nextjs-with-supabase&demo-title=nextjs-with-supabase&demo-description=This+starter+configures+Supabase+Auth+to+use+cookies%2C+making+the+user%27s+session+available+throughout+the+entire+Next.js+app+-+Client+Components%2C+Server+Components%2C+Route+Handlers%2C+Server+Actions+and+Middleware.&demo-url=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2F&external-id=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&demo-image=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2Fopengraph-image.png) + +The above will also clone the Starter kit to your GitHub, you can clone that locally and develop locally. + +If you wish to just develop locally and not deploy to Vercel, [follow the steps below](#clone-and-run-locally). + +## Clone and run locally + +1. You'll first need a Supabase project which can be made [via the Supabase dashboard](https://database.new) + +2. Create a Next.js app using the Supabase Starter template npx command + + ```bash + npx create-next-app --example with-supabase with-supabase-app + ``` + + ```bash + yarn create next-app --example with-supabase with-supabase-app + ``` + + ```bash + pnpm create next-app --example with-supabase with-supabase-app + ``` + +3. Use `cd` to change into the app's directory + + ```bash + cd with-supabase-app + ``` + +4. Rename `.env.example` to `.env.local` and update the following: + + ```env + NEXT_PUBLIC_SUPABASE_URL=[INSERT SUPABASE PROJECT URL] + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=[INSERT SUPABASE PROJECT API PUBLISHABLE OR ANON KEY] + ``` + > [!NOTE] + > This example uses `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, which refers to Supabase's new **publishable** key format. + > Both legacy **anon** keys and new **publishable** keys can be used with this variable name during the transition period. Supabase's dashboard may show `NEXT_PUBLIC_SUPABASE_ANON_KEY`; its value can be used in this example. + > See the [full announcement](https://github.com/orgs/supabase/discussions/29260) for more information. + + Both `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` can be found in [your Supabase project's API settings](https://supabase.com/dashboard/project/_?showConnect=true) + +5. You can now run the Next.js local development server: + + ```bash + npm run dev + ``` + + The starter kit should now be running on [localhost:3000](http://localhost:3000/). + +6. This template comes with the default shadcn/ui style initialized. If you instead want other ui.shadcn styles, delete `components.json` and [re-install shadcn/ui](https://ui.shadcn.com/docs/installation/next) + +> Check out [the docs for Local Development](https://supabase.com/docs/guides/getting-started/local-development) to also run Supabase locally. + +## Feedback and issues + +Please file feedback and issues over on the [Supabase GitHub org](https://github.com/supabase/supabase/issues/new/choose). + +## More Supabase examples + +- [Next.js Subscription Payments Starter](https://github.com/vercel/nextjs-subscription-payments) +- [Cookie-based Auth and the Next.js 13 App Router (free course)](https://youtube.com/playlist?list=PL5S4mPUpp4OtMhpnp93EFSo42iQ40XjbF) +- [Supabase Auth and the Next.js App Router](https://github.com/supabase/supabase/tree/master/examples/auth/nextjs) diff --git a/shop/app/admin/layout.tsx b/shop/app/admin/layout.tsx new file mode 100644 index 0000000..c0054eb --- /dev/null +++ b/shop/app/admin/layout.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+
+ {children} +
+
+
+ ) +} diff --git a/shop/app/admin/page.tsx b/shop/app/admin/page.tsx new file mode 100644 index 0000000..c97de7c --- /dev/null +++ b/shop/app/admin/page.tsx @@ -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 ( +
+

Dashboard

+ +
+ + + Umsatz gesamt + + + +
€12,234.56
+

+20.1% zum Vormonat

+
+
+ + + Bestellungen + + + +
+573
+

+12% zur Vorwoche

+
+
+ + + Produkte + + + +
12
+

+2 neue Module

+
+
+ + + Kunden + + + +
2,450
+

+180 heute

+
+
+
+ +
+ + + Übersicht + + +
+ [Chart Platzhalter] +
+
+
+ + + Letzte Bestellungen + + +
+ {[1, 2, 3].map(i => ( +
+
+ JD +
+
+

John Doe

+

john.doe@example.com

+
+
+€450.00
+
+ ))} +
+
+
+
+
+ ) +} diff --git a/shop/app/admin/products/page.tsx b/shop/app/admin/products/page.tsx new file mode 100644 index 0000000..d2f765c --- /dev/null +++ b/shop/app/admin/products/page.tsx @@ -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 ( +
+
+
+

+ + Produkte & Module +

+

+ Verwalte deine Hauptprodukte und deren zubuchbare Module. +

+
+
+ + + +
+
+ }> + + +
+ ) +} + +async function ProductDataWrapper() { + const products = await getProducts() + return +} diff --git a/shop/app/auth/confirm/route.ts b/shop/app/auth/confirm/route.ts new file mode 100644 index 0000000..b1b1086 --- /dev/null +++ b/shop/app/auth/confirm/route.ts @@ -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`); +} diff --git a/shop/app/auth/error/page.tsx b/shop/app/auth/error/page.tsx new file mode 100644 index 0000000..18b8306 --- /dev/null +++ b/shop/app/auth/error/page.tsx @@ -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 ? ( +

+ Code error: {params.error} +

+ ) : ( +

+ An unspecified error occurred. +

+ )} + + ); +} + +export default function Page({ + searchParams, +}: { + searchParams: Promise<{ error: string }>; +}) { + return ( +
+
+
+ + + + Sorry, something went wrong. + + + + + + + + +
+
+
+ ); +} diff --git a/shop/app/auth/forgot-password/page.tsx b/shop/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000..8dcfbb2 --- /dev/null +++ b/shop/app/auth/forgot-password/page.tsx @@ -0,0 +1,11 @@ +import { ForgotPasswordForm } from "@/components/forgot-password-form"; + +export default function Page() { + return ( +
+
+ +
+
+ ); +} diff --git a/shop/app/auth/login/page.tsx b/shop/app/auth/login/page.tsx new file mode 100644 index 0000000..5efe679 --- /dev/null +++ b/shop/app/auth/login/page.tsx @@ -0,0 +1,11 @@ +import { LoginForm } from "@/components/login-form"; + +export default function Page() { + return ( +
+
+ +
+
+ ); +} diff --git a/shop/app/auth/sign-up-success/page.tsx b/shop/app/auth/sign-up-success/page.tsx new file mode 100644 index 0000000..88f16d3 --- /dev/null +++ b/shop/app/auth/sign-up-success/page.tsx @@ -0,0 +1,32 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function Page() { + return ( +
+
+
+ + + + Thank you for signing up! + + Check your email to confirm + + +

+ You've successfully signed up. Please check your email to + confirm your account before signing in. +

+
+
+
+
+
+ ); +} diff --git a/shop/app/auth/sign-up/page.tsx b/shop/app/auth/sign-up/page.tsx new file mode 100644 index 0000000..d5dca78 --- /dev/null +++ b/shop/app/auth/sign-up/page.tsx @@ -0,0 +1,11 @@ +import { SignUpForm } from "@/components/sign-up-form"; + +export default function Page() { + return ( +
+
+ +
+
+ ); +} diff --git a/shop/app/auth/update-password/page.tsx b/shop/app/auth/update-password/page.tsx new file mode 100644 index 0000000..ff4d176 --- /dev/null +++ b/shop/app/auth/update-password/page.tsx @@ -0,0 +1,11 @@ +import { UpdatePasswordForm } from "@/components/update-password-form"; + +export default function Page() { + return ( +
+
+ +
+
+ ); +} diff --git a/shop/app/favicon.ico b/shop/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/shop/app/favicon.ico differ diff --git a/shop/app/globals.css b/shop/app/globals.css new file mode 100644 index 0000000..f777ed7 --- /dev/null +++ b/shop/app/globals.css @@ -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; + } +} diff --git a/shop/app/layout.tsx b/shop/app/layout.tsx new file mode 100644 index 0000000..96f7cfb --- /dev/null +++ b/shop/app/layout.tsx @@ -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 ( + + + + {children} + + + + ); +} diff --git a/shop/app/opengraph-image.png b/shop/app/opengraph-image.png new file mode 100644 index 0000000..57595e6 Binary files /dev/null and b/shop/app/opengraph-image.png differ diff --git a/shop/app/order/page.tsx b/shop/app/order/page.tsx new file mode 100644 index 0000000..535e85d --- /dev/null +++ b/shop/app/order/page.tsx @@ -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 ( +
+
+
+

+ Konfigurieren Sie Ihre Lösung +

+

+ Wählen Sie das passende Paket und die benötigten Module für Ihr Business. +

+
+ + }> + + +
+
+ ) +} + +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 +} diff --git a/shop/app/page.tsx b/shop/app/page.tsx new file mode 100644 index 0000000..311cabc --- /dev/null +++ b/shop/app/page.tsx @@ -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 ( +
+
+ +
+ +
+ CloudShop + + +
+
+
+
+
+
+
+
+ Version 2.0 ist da +
+

+ Die Zukunft der Software-Bestellung +

+

+ Modulare Lösungen, blitzschnelles Deployment und ein intuitives Interface für Ihre Business-Anforderungen. +

+
+
+ + + + + + +
+
+
+
+ +
+
+
+
+
+ +
+

Modulare Architektur

+

Wählen Sie genau die Module, die Sie benötigen. Keine unnötigen Kosten für Features, die Sie nicht nutzen.

+
+
+
+ +
+

Maximale Sicherheit

+

Enterprise-grade Security mit Supabase Auth und granularen Row Level Security Policies.

+
+
+
+ +
+

Sofort Einsatzbereit

+

Nach der Bestellung wird Ihre Instanz automatisch provisioniert und ist in Minuten online.

+
+
+
+
+
+
+

© 2026 CloudShop GmbH. Alle Rechte vorbehalten.

+ +
+
+ ) +} diff --git a/shop/app/protected/layout.tsx b/shop/app/protected/layout.tsx new file mode 100644 index 0000000..99b0966 --- /dev/null +++ b/shop/app/protected/layout.tsx @@ -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 ( +
+
+ +
+ {children} +
+ + +
+
+ ); +} diff --git a/shop/app/protected/page.tsx b/shop/app/protected/page.tsx new file mode 100644 index 0000000..4f6caa7 --- /dev/null +++ b/shop/app/protected/page.tsx @@ -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 ( +
+
+
+ + This is a protected page that you can only see as an authenticated + user +
+
+
+

Your user details

+
+          
+            
+          
+        
+
+
+

Next steps

+ +
+
+ ); +} diff --git a/shop/app/twitter-image.png b/shop/app/twitter-image.png new file mode 100644 index 0000000..57595e6 Binary files /dev/null and b/shop/app/twitter-image.png differ diff --git a/shop/components.json b/shop/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/shop/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/shop/components/admin/create-product-dialog.tsx b/shop/components/admin/create-product-dialog.tsx new file mode 100644 index 0000000..82270b0 --- /dev/null +++ b/shop/components/admin/create-product-dialog.tsx @@ -0,0 +1,238 @@ +'use client' + +import { useState } from 'react' +import { useForm, useFieldArray } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import * as z from 'zod' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Plus, Trash2, X, PlusCircle } from 'lucide-react' +import { createProduct } from '@/lib/actions/products' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Separator } from '@/components/ui/separator' + +const moduleSchema = z.object({ + name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'), + description: z.string().optional(), + additional_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'), + is_required: z.boolean().default(false), +}) + +const productSchema = z.object({ + name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'), + description: z.string().optional(), + base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'), + is_active: z.boolean().default(true), + modules: z.array(moduleSchema).default([]), +}) + +type ProductFormValues = z.infer + +export function CreateProductDialog({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false) + + const form = useForm({ + resolver: zodResolver(productSchema) as any, + defaultValues: { + name: '', + description: '', + base_price: 0, + is_active: true, + modules: [], + }, + }) + + const { fields, append, remove } = useFieldArray({ + name: 'modules', + control: form.control, + }) + + async function onSubmit(values: ProductFormValues) { + try { + const { modules, ...productData } = values + await createProduct(productData, modules) + setOpen(false) + form.reset() + } catch (error) { + console.error('Error creating product:', error) + } + } + + return ( + + {children} + + + Neues Produkt erstellen + + Definieren Sie das Basisprodukt und fügen Sie optionale oder erforderliche Module hinzu. + + + +
+ + +
+
+ ( + + Produktname + + + + + + )} + /> + ( + + Basispreis (€) + + + + + + )} + /> +
+ + ( + + Beschreibung + + + + + + )} + /> + + + +
+
+

+ + Module +

+ +
+ + {fields.map((field, index) => ( +
+ + +
+ ( + + Modulname + + + + + + )} + /> + ( + + Aufpreis (€) + + + + + + )} + /> +
+ +
+ ( + + + + +
+ Erforderlich +
+
+ )} + /> +
+
+ ))} + + {fields.length === 0 && ( +
+ Noch keine Module hinzugefügt. +
+ )} +
+
+
+ + + + + +
+ +
+
+ ) +} diff --git a/shop/components/admin/product-list.tsx b/shop/components/admin/product-list.tsx new file mode 100644 index 0000000..4707063 --- /dev/null +++ b/shop/components/admin/product-list.tsx @@ -0,0 +1,98 @@ +'use client' + +import { Product } from '@/lib/types' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Edit2, Trash2, Layers } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { deleteProduct } from '@/lib/actions/products' +import { useState } from 'react' + +export function ProductList({ initialProducts }: { initialProducts: Product[] }) { + const [products, setProducts] = useState(initialProducts) + + const handleDelete = async (id: string) => { + if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) { + await deleteProduct(id) + setProducts(products.filter(p => p.id !== id)) + } + } + + return ( + + + Produktübersicht + + Alle konfigurierten Software-Lösungen und Erweiterungen. + + + + + + + Name + Basispreis + Module + Status + Aktionen + + + + {products.map((product) => ( + + +
+ {product.name} +

{product.description}

+
+
+ {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)} + +
+ + {product.modules?.length || 0} Module +
+
+ + + {product.is_active ? "Aktiv" : "Inaktiv"} + + + +
+ + +
+
+
+ ))} + {products.length === 0 && ( + + + Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt! + + + )} +
+
+
+
+ ) +} diff --git a/shop/components/auth-button.tsx b/shop/components/auth-button.tsx new file mode 100644 index 0000000..29f696c --- /dev/null +++ b/shop/components/auth-button.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import { Button } from "./ui/button"; +import { createClient } from "@/lib/supabase/server"; +import { LogoutButton } from "./logout-button"; + +export async function AuthButton() { + const supabase = await createClient(); + + // You can also use getUser() which will be slower. + const { data } = await supabase.auth.getClaims(); + + const user = data?.claims; + + return user ? ( +
+ Hey, {user.email}! + +
+ ) : ( +
+ + +
+ ); +} diff --git a/shop/components/deploy-button.tsx b/shop/components/deploy-button.tsx new file mode 100644 index 0000000..a6b2c26 --- /dev/null +++ b/shop/components/deploy-button.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; +import { Button } from "./ui/button"; + +export function DeployButton() { + return ( + <> + + + + + ); +} diff --git a/shop/components/env-var-warning.tsx b/shop/components/env-var-warning.tsx new file mode 100644 index 0000000..2caa528 --- /dev/null +++ b/shop/components/env-var-warning.tsx @@ -0,0 +1,20 @@ +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; + +export function EnvVarWarning() { + return ( +
+ + Supabase environment variables required + +
+ + +
+
+ ); +} diff --git a/shop/components/forgot-password-form.tsx b/shop/components/forgot-password-form.tsx new file mode 100644 index 0000000..fd96eae --- /dev/null +++ b/shop/components/forgot-password-form.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { createClient } from "@/lib/supabase/client"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Link from "next/link"; +import { useState } from "react"; + +export function ForgotPasswordForm({ + className, + ...props +}: React.ComponentPropsWithoutRef<"div">) { + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleForgotPassword = async (e: React.FormEvent) => { + e.preventDefault(); + const supabase = createClient(); + setIsLoading(true); + setError(null); + + try { + // The url which will be included in the email. This URL needs to be configured in your redirect URLs in the Supabase dashboard at https://supabase.com/dashboard/project/_/auth/url-configuration + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${window.location.origin}/auth/update-password`, + }); + if (error) throw error; + setSuccess(true); + } catch (error: unknown) { + setError(error instanceof Error ? error.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {success ? ( + + + Check Your Email + Password reset instructions sent + + +

+ If you registered using your email and password, you will receive + a password reset email. +

+
+
+ ) : ( + + + Reset Your Password + + Type in your email and we'll send you a link to reset your + password + + + +
+
+
+ + setEmail(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+ Already have an account?{" "} + + Login + +
+
+
+
+ )} +
+ ); +} diff --git a/shop/components/hero.tsx b/shop/components/hero.tsx new file mode 100644 index 0000000..4a31bd8 --- /dev/null +++ b/shop/components/hero.tsx @@ -0,0 +1,44 @@ +import { NextLogo } from "./next-logo"; +import { SupabaseLogo } from "./supabase-logo"; + +export function Hero() { + return ( +
+
+ + + + + + + +
+

Supabase and Next.js Starter Template

+

+ The fastest way to build apps with{" "} + + Supabase + {" "} + and{" "} + + Next.js + +

+
+
+ ); +} diff --git a/shop/components/login-form.tsx b/shop/components/login-form.tsx new file mode 100644 index 0000000..23040a0 --- /dev/null +++ b/shop/components/login-form.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { createClient } from "@/lib/supabase/client"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export function LoginForm({ + className, + ...props +}: React.ComponentPropsWithoutRef<"div">) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + const supabase = createClient(); + setIsLoading(true); + setError(null); + + try { + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + if (error) throw error; + // Update this route to redirect to an authenticated route. The user already has an active session. + router.push("/protected"); + } catch (error: unknown) { + setError(error instanceof Error ? error.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + Login + + Enter your email below to login to your account + + + +
+
+
+ + setEmail(e.target.value)} + /> +
+
+
+ + + Forgot your password? + +
+ setPassword(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+
+ ); +} diff --git a/shop/components/logout-button.tsx b/shop/components/logout-button.tsx new file mode 100644 index 0000000..4b8b90b --- /dev/null +++ b/shop/components/logout-button.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { createClient } from "@/lib/supabase/client"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; + +export function LogoutButton() { + const router = useRouter(); + + const logout = async () => { + const supabase = createClient(); + await supabase.auth.signOut(); + router.push("/auth/login"); + }; + + return ; +} diff --git a/shop/components/next-logo.tsx b/shop/components/next-logo.tsx new file mode 100644 index 0000000..7b04400 --- /dev/null +++ b/shop/components/next-logo.tsx @@ -0,0 +1,46 @@ +export function NextLogo() { + return ( + + + + + + + + + + + ); +} diff --git a/shop/components/order-wizard.tsx b/shop/components/order-wizard.tsx new file mode 100644 index 0000000..e08bcd1 --- /dev/null +++ b/shop/components/order-wizard.tsx @@ -0,0 +1,333 @@ +'use client' + +import { useState, useMemo } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Product, ProductModule, Profile } from '@/lib/types' +import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, CreditCard, ShieldCheck } from 'lucide-react' +import { submitOrder } from '@/lib/actions/orders' + +export function OrderWizard({ products, initialProfile }: { products: Product[], initialProfile: Profile | null }) { + const [step, setStep] = useState(1) + const [selectedProduct, setSelectedProduct] = useState(products[0] || null) + const [selectedModules, setSelectedModules] = useState([]) + const [customerData, setCustomerData] = useState>(initialProfile || { + company_name: '', + vat_id: '', + first_name: '', + last_name: '', + address: '', + city: '', + zip_code: '' + }) + + const totalPrice = useMemo(() => { + if (!selectedProduct) return 0 + let total = Number(selectedProduct.base_price) + selectedModules.forEach(modId => { + const mod = selectedProduct.modules?.find(m => m.id === modId) + if (mod) total += Number(mod.additional_price) + }) + return total + }, [selectedProduct, selectedModules]) + + const nextStep = () => setStep(s => s + 1) + const prevStep = () => setStep(s => s - 1) + + const toggleModule = (id: string) => { + setSelectedModules(prev => + prev.includes(id) ? prev.filter(m => m !== id) : [...prev, id] + ) + } + + const handleSubmit = async () => { + if (!selectedProduct) return + try { + await submitOrder({ + product_id: selectedProduct.id, + selected_modules: selectedModules, + total_amount: totalPrice, + customer_details: customerData + }) + alert('Bestellung erfolgreich aufgegeben!') + window.location.href = '/' + } catch (error) { + console.error(error) + alert('Fehler bei der Bestellung.') + } + } + + return ( +
+ {/* Progress Stepper */} +
+
+ {[1, 2, 3].map((s) => ( +
= s ? 'bg-primary text-white scale-110 shadow-[0_0_15px_rgba(var(--primary),0.5)]' : 'bg-slate-800 text-slate-400' + }`} + > + {step > s ? : s} +
+ ))} +
+ + + {step === 1 && ( + +
+
+ + + + + Software wählen + + Wählen Sie Ihr Basispaket und optionale Erweiterungen. + + + { + const p = products.find(prod => prod.id === id) + if (p) { + setSelectedProduct(p) + setSelectedModules([]) // Reset modules when product changes + } + }} + className="grid gap-4" + > + {products.map(product => ( +
+ + +
+ ))} +
+ + {selectedProduct && selectedProduct.modules && selectedProduct.modules.length > 0 && ( +
+ +
+ {selectedProduct.modules.map(module => ( +
+ toggleModule(module.id)} + /> +
+ +

{module.description}

+
+
+ ))} +
+
+ )} +
+
+
+ +
+ + + Zusammenfassung + + +
+ Basispreis: + {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(selectedProduct?.base_price || 0)} +
+ {selectedModules.map(modId => { + const mod = selectedProduct?.modules?.find(m => m.id === modId) + return ( +
+ {mod?.name}: + +{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod?.additional_price || 0)} +
+ ) + })} + +
+ Gesamt: + {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} +
+
+ + + +
+
+
+
+ )} + + {step === 2 && ( + + + + + + Kundendaten + + Wir benötigen Ihre Daten für die Rechnungsstellung. + + +
+ + setCustomerData({...customerData, company_name: e.target.value})} + placeholder="GmbH / Einzelunternehmen" + className="bg-white/5 border-white/10" + /> +
+
+ + setCustomerData({...customerData, vat_id: e.target.value})} + placeholder="DE123456789" + className="bg-white/5 border-white/10" + /> +
+
+ + setCustomerData({...customerData, first_name: e.target.value})} + className="bg-white/5 border-white/10" + /> +
+
+ + setCustomerData({...customerData, last_name: e.target.value})} + className="bg-white/5 border-white/10" + /> +
+
+ + setCustomerData({...customerData, address: e.target.value})} + className="bg-white/5 border-white/10" + /> +
+
+ + setCustomerData({...customerData, zip_code: e.target.value})} + className="bg-white/5 border-white/10" + /> +
+
+ + setCustomerData({...customerData, city: e.target.value})} + className="bg-white/5 border-white/10" + /> +
+
+ + + + +
+
+ )} + + {step === 3 && ( + + + +
+ +
+ Bestellung prüfen + Fast fertig! Bitte überprüfen Sie Ihre Auswahl. +
+ +
+
+ {selectedProduct?.name} + {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat +
+
+

Inklusive: {selectedModules.length > 0 ? selectedModules.map(id => selectedProduct?.modules?.find(m => m.id === id)?.name).join(', ') : 'Keine Zusatzmodule'}

+ +

{customerData.company_name}

+

{customerData.address}, {customerData.zip_code} {customerData.city}

+
+
+ +

+ Mit dem Klick auf "Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die Datenschutzerklärung. +

+
+ + + + +
+
+ )} +
+
+ ) +} diff --git a/shop/components/sign-up-form.tsx b/shop/components/sign-up-form.tsx new file mode 100644 index 0000000..6c4b369 --- /dev/null +++ b/shop/components/sign-up-form.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { createClient } from "@/lib/supabase/client"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export function SignUpForm({ + className, + ...props +}: React.ComponentPropsWithoutRef<"div">) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [repeatPassword, setRepeatPassword] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + const handleSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + const supabase = createClient(); + setIsLoading(true); + setError(null); + + if (password !== repeatPassword) { + setError("Passwords do not match"); + setIsLoading(false); + return; + } + + try { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${window.location.origin}/protected`, + }, + }); + if (error) throw error; + router.push("/auth/sign-up-success"); + } catch (error: unknown) { + setError(error instanceof Error ? error.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + Sign up + Create a new account + + +
+
+
+ + setEmail(e.target.value)} + /> +
+
+
+ +
+ setPassword(e.target.value)} + /> +
+
+
+ +
+ setRepeatPassword(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+ Already have an account?{" "} + + Login + +
+
+
+
+
+ ); +} diff --git a/shop/components/supabase-logo.tsx b/shop/components/supabase-logo.tsx new file mode 100644 index 0000000..d3461aa --- /dev/null +++ b/shop/components/supabase-logo.tsx @@ -0,0 +1,102 @@ +export function SupabaseLogo() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/shop/components/theme-switcher.tsx b/shop/components/theme-switcher.tsx new file mode 100644 index 0000000..d838e40 --- /dev/null +++ b/shop/components/theme-switcher.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Laptop, Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +const ThemeSwitcher = () => { + const [mounted, setMounted] = useState(false); + const { theme, setTheme } = useTheme(); + + // useEffect only runs on the client, so now we can safely show the UI + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + const ICON_SIZE = 16; + + return ( + + + + + + setTheme(e)} + > + + {" "} + Light + + + {" "} + Dark + + + {" "} + System + + + + + ); +}; + +export { ThemeSwitcher }; diff --git a/shop/components/tutorial/code-block.tsx b/shop/components/tutorial/code-block.tsx new file mode 100644 index 0000000..9f1b13d --- /dev/null +++ b/shop/components/tutorial/code-block.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; + +const CopyIcon = () => ( + + + + +); + +const CheckIcon = () => ( + + + +); + +export function CodeBlock({ code }: { code: string }) { + const [icon, setIcon] = useState(CopyIcon); + + const copy = async () => { + await navigator?.clipboard?.writeText(code); + setIcon(CheckIcon); + setTimeout(() => setIcon(CopyIcon), 2000); + }; + + return ( +
+      
+      {code}
+    
+ ); +} diff --git a/shop/components/tutorial/connect-supabase-steps.tsx b/shop/components/tutorial/connect-supabase-steps.tsx new file mode 100644 index 0000000..611c500 --- /dev/null +++ b/shop/components/tutorial/connect-supabase-steps.tsx @@ -0,0 +1,62 @@ +import { TutorialStep } from "./tutorial-step"; + +export function ConnectSupabaseSteps() { + return ( +
    + +

    + Head over to{" "} + + database.new + {" "} + and create a new Supabase project. +

    +
    + + +

    + Rename the{" "} + + .env.example + {" "} + file in your Next.js app to{" "} + + .env.local + {" "} + and populate with values from{" "} + + your Supabase project's API Settings + + . +

    +
    + + +

    + You may need to quit your Next.js development server and run{" "} + + npm run dev + {" "} + again to load the new environment variables. +

    +
    + + +

    + You may need to refresh the page for Next.js to load the new + environment variables. +

    +
    +
+ ); +} diff --git a/shop/components/tutorial/fetch-data-steps.tsx b/shop/components/tutorial/fetch-data-steps.tsx new file mode 100644 index 0000000..c54aa83 --- /dev/null +++ b/shop/components/tutorial/fetch-data-steps.tsx @@ -0,0 +1,163 @@ +import { TutorialStep } from "./tutorial-step"; +import { CodeBlock } from "./code-block"; + +const create = `create table notes ( + id bigserial primary key, + title text +); + +insert into notes(title) +values + ('Today I created a Supabase project.'), + ('I added some data and queried it from Next.js.'), + ('It was awesome!'); +`.trim(); + +const rls = `alter table notes enable row level security; +create policy "Allow public read access" on notes +for select +using (true);`.trim(); + +const server = `import { createClient } from '@/lib/supabase/server' + +export default async function Page() { + const supabase = await createClient() + const { data: notes } = await supabase.from('notes').select() + + return
{JSON.stringify(notes, null, 2)}
+} +`.trim(); + +const client = `'use client' + +import { createClient } from '@/lib/supabase/client' +import { useEffect, useState } from 'react' + +export default function Page() { + const [notes, setNotes] = useState(null) + const supabase = createClient() + + useEffect(() => { + const getData = async () => { + const { data } = await supabase.from('notes').select() + setNotes(data) + } + getData() + }, []) + + return
{JSON.stringify(notes, null, 2)}
+} +`.trim(); + +export function FetchDataSteps() { + return ( +
    + +

    + Head over to the{" "} + + Table Editor + {" "} + for your Supabase project to create a table and insert some example + data. If you're stuck for creativity, you can copy and paste the + following into the{" "} + + SQL Editor + {" "} + and click RUN! +

    + +
    + + +

    + Supabase enables Row Level Security (RLS) by default. To query data + from your notes table, you need to add a policy. You can + do this in the{" "} + + Table Editor + {" "} + or via the{" "} + + SQL Editor + + . +

    +

    + For example, you can run the following SQL to allow public read + access: +

    + +

    + You can learn more about RLS in the{" "} + + Supabase docs + + . +

    +
    + + +

    + To create a Supabase client and query data from an Async Server + Component, create a new page.tsx file at{" "} + + /app/notes/page.tsx + {" "} + and add the following. +

    + +

    Alternatively, you can use a Client Component.

    + +
    + + +

    + Head over to the{" "} + + Supabase UI library + {" "} + and try installing some blocks. For example, you can install a + Realtime Chat block by running: +

    + +
    + + +

    You're ready to launch your product to the world! 🚀

    +
    +
+ ); +} diff --git a/shop/components/tutorial/sign-up-user-steps.tsx b/shop/components/tutorial/sign-up-user-steps.tsx new file mode 100644 index 0000000..47cce0d --- /dev/null +++ b/shop/components/tutorial/sign-up-user-steps.tsx @@ -0,0 +1,91 @@ +import Link from "next/link"; +import { TutorialStep } from "./tutorial-step"; +import { ArrowUpRight } from "lucide-react"; + +export function SignUpUserSteps() { + return ( +
    + {process.env.VERCEL_ENV === "preview" || + process.env.VERCEL_ENV === "production" ? ( + +

    It looks like this App is hosted on Vercel.

    +

    + This particular deployment is + + "{process.env.VERCEL_ENV}" + {" "} + on + + https://{process.env.VERCEL_URL} + + . +

    +

    + You will need to{" "} + + update your Supabase project + {" "} + with redirect URLs based on your Vercel deployment URLs. +

    +
      +
    • + -{" "} + + http://localhost:3000/** + +
    • +
    • + -{" "} + + {`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}/**`} + +
    • +
    • + -{" "} + + {`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL?.replace( + ".vercel.app", + "", + )}-*-[vercel-team-url].vercel.app/**`} + {" "} + (Vercel Team URL can be found in{" "} + + Vercel Team settings + + ) +
    • +
    + + Redirect URLs Docs + +
    + ) : null} + +

    + Head over to the{" "} + + Sign up + {" "} + page and sign up your first user. It's okay if this is just you + for now. Your awesome idea will have plenty of users later! +

    +
    +
+ ); +} diff --git a/shop/components/tutorial/tutorial-step.tsx b/shop/components/tutorial/tutorial-step.tsx new file mode 100644 index 0000000..0ab9cd4 --- /dev/null +++ b/shop/components/tutorial/tutorial-step.tsx @@ -0,0 +1,30 @@ +import { Checkbox } from "../ui/checkbox"; + +export function TutorialStep({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
  • + + +
  • + ); +} diff --git a/shop/components/ui/badge.tsx b/shop/components/ui/badge.tsx new file mode 100644 index 0000000..f795980 --- /dev/null +++ b/shop/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
    + ); +} + +export { Badge, badgeVariants }; diff --git a/shop/components/ui/button.tsx b/shop/components/ui/button.tsx new file mode 100644 index 0000000..4d38506 --- /dev/null +++ b/shop/components/ui/button.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/shop/components/ui/card.tsx b/shop/components/ui/card.tsx new file mode 100644 index 0000000..acf57dc --- /dev/null +++ b/shop/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/shop/components/ui/checkbox.tsx b/shop/components/ui/checkbox.tsx new file mode 100644 index 0000000..f5a7e43 --- /dev/null +++ b/shop/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import { CheckIcon } from "lucide-react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/shop/components/ui/dialog.tsx b/shop/components/ui/dialog.tsx new file mode 100644 index 0000000..84bdef4 --- /dev/null +++ b/shop/components/ui/dialog.tsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
    + {children} + {showCloseButton && ( + + + + )} +
    + ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/shop/components/ui/dropdown-menu.tsx b/shop/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..78a751e --- /dev/null +++ b/shop/components/ui/dropdown-menu.tsx @@ -0,0 +1,201 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/shop/components/ui/form.tsx b/shop/components/ui/form.tsx new file mode 100644 index 0000000..f371fea --- /dev/null +++ b/shop/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from "react" +import type { Label as LabelPrimitive } from "radix-ui" +import { Slot } from "radix-ui" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
    + + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +