feat: Wabenhain Honig-Shop Anwendung
Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -0,0 +1,195 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Gemeinsame, gehaertete Basisfelder: Laengenlimits verhindern uebergrosse
|
||||
// Payloads in der Datenbank, trim/lowercase normalisieren E-Mail-Adressen,
|
||||
// damit "[email protected]" und "[email protected] " dasselbe Konto treffen.
|
||||
const emailSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.max(254, "Die E-Mail-Adresse ist zu lang.")
|
||||
.email("Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||
|
||||
const nameSchema = (message: string) => z.string().trim().min(1, message).max(100);
|
||||
|
||||
export const newsletterSchema = z.object({
|
||||
email: emailSchema,
|
||||
});
|
||||
export type NewsletterInput = z.infer<typeof newsletterSchema>;
|
||||
|
||||
export const stockNotificationSchema = z.object({
|
||||
productId: z.string().min(1).max(64),
|
||||
email: emailSchema,
|
||||
});
|
||||
export type StockNotificationInput = z.infer<typeof stockNotificationSchema>;
|
||||
|
||||
export const returnRequestSchema = z.object({
|
||||
orderId: z.string().min(1).max(64),
|
||||
reason: z.string().trim().min(3, "Bitte gib einen Grund für die Rücksendung an.").max(2000),
|
||||
});
|
||||
export type ReturnRequestInput = z.infer<typeof returnRequestSchema>;
|
||||
|
||||
export const businessInquirySchema = z.object({
|
||||
companyName: z.string().trim().min(2, "Bitte gib den Firmennamen an.").max(200),
|
||||
contactPerson: z.string().trim().min(2, "Bitte gib einen Ansprechpartner an.").max(100),
|
||||
email: emailSchema,
|
||||
phone: z.string().trim().max(40).optional(),
|
||||
desiredQuantity: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Bitte gib eine Menge von mindestens 1 an.")
|
||||
.max(100_000),
|
||||
desiredDeliveryDate: z.string().max(30).optional(),
|
||||
package: z.enum(["KLASSIK", "GESCHENK", "INDIVIDUELL"]),
|
||||
message: z.string().trim().max(5000).optional(),
|
||||
});
|
||||
export type BusinessInquiryInput = z.infer<typeof businessInquirySchema>;
|
||||
|
||||
export const registerSchema = z.object({
|
||||
firstName: nameSchema("Bitte gib deinen Vornamen an."),
|
||||
lastName: nameSchema("Bitte gib deinen Nachnamen an."),
|
||||
email: emailSchema,
|
||||
// bcrypt verarbeitet maximal 72 Bytes — laengere Passwoerter wuerden sonst
|
||||
// stillschweigend abgeschnitten, deshalb hartes Limit mit klarer Meldung.
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Das Passwort muss mindestens 8 Zeichen lang sein.")
|
||||
.max(72, "Das Passwort darf höchstens 72 Zeichen lang sein."),
|
||||
});
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: z.string().min(1, "Bitte gib dein Passwort ein.").max(72),
|
||||
});
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
|
||||
export const requestPasswordResetSchema = z.object({
|
||||
email: emailSchema,
|
||||
});
|
||||
export type RequestPasswordResetInput = z.infer<typeof requestPasswordResetSchema>;
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().trim().min(1, "Ungültiger oder abgelaufener Link."),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Das Passwort muss mindestens 8 Zeichen lang sein.")
|
||||
.max(72, "Das Passwort darf höchstens 72 Zeichen lang sein."),
|
||||
});
|
||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||
|
||||
export const adminCreateCustomerSchema = z.object({
|
||||
firstName: nameSchema("Bitte gib den Vornamen an."),
|
||||
lastName: nameSchema("Bitte gib den Nachnamen an."),
|
||||
email: emailSchema,
|
||||
role: z.enum(["KUNDE", "ADMIN"]),
|
||||
});
|
||||
export type AdminCreateCustomerInput = z.infer<typeof adminCreateCustomerSchema>;
|
||||
|
||||
export const adminUpdateCustomerSchema = z.object({
|
||||
firstName: nameSchema("Bitte gib den Vornamen an."),
|
||||
lastName: nameSchema("Bitte gib den Nachnamen an."),
|
||||
email: emailSchema,
|
||||
phone: z.string().trim().max(40).optional(),
|
||||
role: z.enum(["KUNDE", "ADMIN"]),
|
||||
});
|
||||
export type AdminUpdateCustomerInput = z.infer<typeof adminUpdateCustomerSchema>;
|
||||
|
||||
// Nur die Zahlenfelder aus dem Admin-Produktformular — name/description/
|
||||
// tasteTags/category/consistency laufen weiterhin ueber die einfachen
|
||||
// str()/strOrNull()-Helfer in admin/actions/products.ts, da dort keine
|
||||
// Vorzeichen-/Bereichspruefung noetig ist. num() dort prueft nur NaN/
|
||||
// Infinity, nicht Vorzeichen — ohne dieses Schema waeren negative Preise/
|
||||
// Gewichte/Intensitaeten ueber ein manipuliertes Formularfeld moeglich.
|
||||
export const adminProductNumbersSchema = z.object({
|
||||
intensityMild: z.coerce.number().int().min(1).max(5),
|
||||
intensityFloral: z.coerce.number().int().min(1).max(5),
|
||||
intensityKraeftig: z.coerce.number().int().min(1).max(5),
|
||||
weightGrams: z.coerce.number().int().min(1, "Das Gewicht muss mindestens 1 g betragen."),
|
||||
priceCents: z.coerce.number().int().min(0, "Der Preis darf nicht negativ sein."),
|
||||
pricePerKgCents: z.coerce.number().int().min(0, "Der Kilopreis darf nicht negativ sein."),
|
||||
quantity: z.coerce.number().int().min(0, "Der Lagerbestand darf nicht negativ sein."),
|
||||
});
|
||||
export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>;
|
||||
|
||||
export const shippingTierSchema = z
|
||||
.object({
|
||||
minQuantity: z.coerce.number().int().min(0, "Die Mindestmenge darf nicht negativ sein."),
|
||||
maxQuantity: z.coerce.number().int().min(1, "Die Höchstmenge muss mindestens 1 sein.").optional(),
|
||||
priceCents: z.coerce.number().int().min(0, "Der Preis darf nicht negativ sein."),
|
||||
})
|
||||
.refine((data) => data.maxQuantity === undefined || data.maxQuantity >= data.minQuantity, {
|
||||
message: "Die Höchstmenge muss größer oder gleich der Mindestmenge sein.",
|
||||
path: ["maxQuantity"],
|
||||
});
|
||||
export type ShippingTierInput = z.infer<typeof shippingTierSchema>;
|
||||
|
||||
export const addressSchema = z.object({
|
||||
label: z.string().trim().min(1, "Bitte gib eine Bezeichnung an.").max(60),
|
||||
firstName: nameSchema("Bitte gib den Vornamen an."),
|
||||
lastName: nameSchema("Bitte gib den Nachnamen an."),
|
||||
street: z.string().trim().min(1, "Bitte gib die Straße an.").max(200),
|
||||
zip: z.string().trim().min(4, "Bitte gib eine gültige Postleitzahl an.").max(10),
|
||||
city: z.string().trim().min(1, "Bitte gib den Ort an.").max(100),
|
||||
country: z.string().trim().max(2).default("DE"),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
export type AddressInput = z.infer<typeof addressSchema>;
|
||||
|
||||
export const checkoutSchema = z.object({
|
||||
email: emailSchema,
|
||||
firstName: nameSchema("Bitte gib deinen Vornamen an."),
|
||||
lastName: nameSchema("Bitte gib deinen Nachnamen an."),
|
||||
phone: z.string().trim().max(40).optional(),
|
||||
fulfillmentMethod: z.enum(["VERSAND", "ABHOLUNG"]),
|
||||
street: z.string().trim().max(200).optional(),
|
||||
zip: z.string().trim().max(10).optional(),
|
||||
city: z.string().trim().max(100).optional(),
|
||||
country: z.string().trim().max(2).default("DE"),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
productId: z.string().min(1).max(64),
|
||||
quantity: z.coerce.number().int().min(1).max(20),
|
||||
})
|
||||
)
|
||||
.min(1, "Der Warenkorb ist leer.")
|
||||
.max(50, "Zu viele verschiedene Positionen im Warenkorb."),
|
||||
discountCode: z.string().trim().max(50).optional(),
|
||||
giftMessage: z.string().trim().max(500, "Die Geschenknachricht darf höchstens 500 Zeichen lang sein.").optional(),
|
||||
});
|
||||
export type CheckoutInput = z.infer<typeof checkoutSchema>;
|
||||
|
||||
export const reviewSchema = z.object({
|
||||
productId: z.string().min(1).max(64),
|
||||
rating: z.coerce.number().int().min(1, "Bitte wähle eine Bewertung von 1 bis 5 Sternen.").max(5),
|
||||
comment: z.string().trim().max(2000).optional(),
|
||||
});
|
||||
export type ReviewInput = z.infer<typeof reviewSchema>;
|
||||
|
||||
export const discountCodeSchema = z
|
||||
.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, "Der Code muss mindestens 3 Zeichen lang sein.")
|
||||
.max(30)
|
||||
.toUpperCase(),
|
||||
type: z.enum(["PERCENT", "FIXED"]),
|
||||
value: z.coerce.number().int().min(1, "Der Wert muss mindestens 1 sein."),
|
||||
minOrderCents: z.coerce.number().int().min(0, "Der Mindestbestellwert darf nicht negativ sein.").default(0),
|
||||
minQuantity: z.coerce.number().int().min(1, "Die Mindestmenge muss mindestens 1 sein.").optional(),
|
||||
maxUses: z.coerce.number().int().min(1, "Das Nutzungslimit muss mindestens 1 sein.").optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
validFrom: z.coerce.date().optional(),
|
||||
validUntil: z.coerce.date().optional(),
|
||||
})
|
||||
.refine((data) => data.type !== "PERCENT" || data.value <= 100, {
|
||||
message: "Ein Prozent-Rabatt darf höchstens 100 sein.",
|
||||
path: ["value"],
|
||||
})
|
||||
.refine((data) => !data.validFrom || !data.validUntil || data.validFrom <= data.validUntil, {
|
||||
message: "Das Enddatum muss nach dem Startdatum liegen.",
|
||||
path: ["validUntil"],
|
||||
});
|
||||
export type DiscountCodeInput = z.infer<typeof discountCodeSchema>;
|
||||
Reference in new issue
Block a user