Enhance checkout flow with new components and validation
- Added AddressForm and CheckoutForm components for user input during checkout. - Implemented validation using Zod and VeeValidate for billing address fields. - Created OrderSummary and MockPayPalButton components for order confirmation and payment simulation. - Updated CartSheet and CartSidebar to navigate to the new checkout page at '/kasse'. - Introduced new API endpoints for validating checkout data and creating orders. - Enhanced user experience with responsive design and error handling. These changes complete the checkout functionality, allowing users to enter billing information, simulate payment, and confirm orders.
This commit is contained in:
74
server/api/checkout/validate.post.ts
Normal file
74
server/api/checkout/validate.post.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* POST /api/checkout/validate
|
||||
*
|
||||
* Validates checkout data before creating an order
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* salutation: 'male' | 'female' | 'other',
|
||||
* firstName: string,
|
||||
* lastName: string,
|
||||
* dateOfBirth: string (YYYY-MM-DD),
|
||||
* street: string,
|
||||
* postCode: string (5 digits),
|
||||
* city: string,
|
||||
* countryCode: string (ISO 3166-1 alpha-2, default: 'DE'),
|
||||
* saveAddress: boolean (optional)
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* message: string
|
||||
* }
|
||||
*
|
||||
* Errors:
|
||||
* - 401: Not authenticated
|
||||
* - 400: Empty cart
|
||||
* - 422: Validation errors
|
||||
*/
|
||||
|
||||
import { checkoutSchema } from '../../utils/schemas/checkout'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1. Require authentication
|
||||
const { user } = await requireUserSession(event)
|
||||
|
||||
// 2. Check if cart has items
|
||||
const cart = await getOrCreateCart(event)
|
||||
const cartSummary = await getCartWithItems(cart.id)
|
||||
|
||||
if (cartSummary.itemCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Cart is empty',
|
||||
message: 'Dein Warenkorb ist leer. Füge Produkte hinzu, um fortzufahren.',
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Validate checkout data
|
||||
const body = await readBody(event)
|
||||
|
||||
try {
|
||||
const validatedData = await checkoutSchema.parseAsync(body)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Checkout-Daten sind gültig',
|
||||
data: validatedData,
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Zod validation errors
|
||||
if (error.errors) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
statusMessage: 'Validation error',
|
||||
message: 'Bitte überprüfe deine Eingaben',
|
||||
data: error.errors,
|
||||
})
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
throw error
|
||||
}
|
||||
})
|
||||
89
server/api/orders/[id].get.ts
Normal file
89
server/api/orders/[id].get.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* GET /api/orders/[id]
|
||||
*
|
||||
* Fetch order details by ID
|
||||
*
|
||||
* Security:
|
||||
* - Requires authentication
|
||||
* - Users can only access their own orders
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* id: string
|
||||
* orderNumber: string
|
||||
* totalAmount: string
|
||||
* status: string
|
||||
* billingAddress: BillingAddress
|
||||
* items: OrderItem[]
|
||||
* createdAt: Date
|
||||
* }
|
||||
*/
|
||||
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { orders, orderItems } from '../../database/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Require authentication
|
||||
const { user } = await requireUserSession(event)
|
||||
|
||||
// Get order ID from URL parameter
|
||||
const orderId = getRouterParam(event, 'id')
|
||||
|
||||
if (!orderId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Order ID is required',
|
||||
})
|
||||
}
|
||||
|
||||
const db = useDatabase()
|
||||
|
||||
// Fetch order with items
|
||||
const order = await db.query.orders.findFirst({
|
||||
where: and(eq(orders.id, orderId), eq(orders.userId, user.id)),
|
||||
with: {
|
||||
items: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Order not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Transform items to include price and product snapshot data
|
||||
const transformedItems = order.items.map((item: any) => ({
|
||||
id: item.id,
|
||||
orderId: item.orderId,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
priceSnapshot: item.priceSnapshot,
|
||||
productSnapshot: item.productSnapshot,
|
||||
product: {
|
||||
id: item.product.id,
|
||||
name: item.product.name,
|
||||
description: item.product.description,
|
||||
imageUrl: item.product.imageUrl,
|
||||
},
|
||||
subtotal: Number.parseFloat(item.priceSnapshot) * item.quantity,
|
||||
}))
|
||||
|
||||
return {
|
||||
id: order.id,
|
||||
orderNumber: order.orderNumber,
|
||||
totalAmount: order.totalAmount,
|
||||
status: order.status,
|
||||
billingAddress: order.billingAddress,
|
||||
items: transformedItems,
|
||||
paymentId: order.paymentId,
|
||||
paymentCompletedAt: order.paymentCompletedAt,
|
||||
createdAt: order.createdAt,
|
||||
updatedAt: order.updatedAt,
|
||||
}
|
||||
})
|
||||
85
server/api/orders/confirm/[id].post.ts
Normal file
85
server/api/orders/confirm/[id].post.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* POST /api/orders/confirm/[id]
|
||||
*
|
||||
* Confirm an order after mock payment
|
||||
*
|
||||
* Security:
|
||||
* - Requires authentication
|
||||
* - Users can only confirm their own orders
|
||||
* - Order must be in 'pending' status
|
||||
*
|
||||
* Behavior:
|
||||
* - Updates order status: 'pending' → 'completed'
|
||||
* - Stores completion timestamp
|
||||
* - Clears user's cart
|
||||
* - Returns order details
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true
|
||||
* order: Order
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { orders, cartItems } from '../../../database/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Require authentication
|
||||
const { user } = await requireUserSession(event)
|
||||
|
||||
// Get order ID from URL parameter
|
||||
const orderId = getRouterParam(event, 'id')
|
||||
|
||||
if (!orderId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Order ID is required',
|
||||
})
|
||||
}
|
||||
|
||||
const db = useDatabase()
|
||||
|
||||
// Fetch order
|
||||
const order = await db.query.orders.findFirst({
|
||||
where: and(eq(orders.id, orderId), eq(orders.userId, user.id)),
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Order not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate order status
|
||||
if (order.status !== 'pending') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Order cannot be confirmed. Current status: ${order.status}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Update order status to completed
|
||||
const [updatedOrder] = await db
|
||||
.update(orders)
|
||||
.set({
|
||||
status: 'completed',
|
||||
paymentCompletedAt: new Date(),
|
||||
paymentId: `MOCK-${Date.now()}`, // Mock payment ID
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(orders.id, orderId))
|
||||
.returning()
|
||||
|
||||
// Clear user's cart
|
||||
const cart = await getOrCreateCart(event)
|
||||
await db.delete(cartItems).where(eq(cartItems.cartId, cart.id))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
order: updatedOrder,
|
||||
message: 'Bestellung erfolgreich bestätigt',
|
||||
}
|
||||
})
|
||||
145
server/api/orders/create.post.ts
Normal file
145
server/api/orders/create.post.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* POST /api/orders/create
|
||||
*
|
||||
* Create a new order from the user's cart
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* salutation: 'male' | 'female' | 'other'
|
||||
* firstName: string
|
||||
* lastName: string
|
||||
* dateOfBirth: string (YYYY-MM-DD)
|
||||
* street: string
|
||||
* postCode: string
|
||||
* city: string
|
||||
* countryCode: string
|
||||
* saveAddress: boolean (optional, default: false)
|
||||
* }
|
||||
*
|
||||
* Behavior:
|
||||
* - Creates order with status 'pending'
|
||||
* - Copies cart items to order_items with price snapshot
|
||||
* - Stores billing address snapshot in order
|
||||
* - Generates unique order number (format: EXP-2025-00001)
|
||||
* - Optionally saves address to user profile
|
||||
* - Does NOT clear cart (cart is cleared after order confirmation)
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true
|
||||
* orderId: string
|
||||
* orderNumber: string
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
|
||||
import { checkoutSchema } from '../../utils/schemas/checkout'
|
||||
import { orders, orderItems, users } from '../../database/schema'
|
||||
import { eq, desc, sql } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Require authentication
|
||||
const { user } = await requireUserSession(event)
|
||||
|
||||
// Validate request body
|
||||
const body = await readBody(event)
|
||||
const checkoutData = await checkoutSchema.parseAsync(body)
|
||||
|
||||
const db = useDatabase()
|
||||
|
||||
// Get user's cart
|
||||
const cart = await getOrCreateCart(event)
|
||||
const cartSummary = await getCartWithItems(cart.id)
|
||||
|
||||
// Validate cart has items
|
||||
if (cartSummary.items.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Warenkorb ist leer',
|
||||
})
|
||||
}
|
||||
|
||||
// Generate unique order number
|
||||
// Format: EXP-YYYY-NNNNN (e.g., EXP-2025-00001)
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
// Get the highest order number for this year
|
||||
const lastOrder = await db.query.orders.findFirst({
|
||||
where: sql`${orders.orderNumber} LIKE ${`EXP-${year}-%`}`,
|
||||
orderBy: desc(orders.createdAt),
|
||||
})
|
||||
|
||||
let sequenceNumber = 1
|
||||
if (lastOrder) {
|
||||
// Extract sequence number from last order number (EXP-2025-00123 -> 123)
|
||||
const match = lastOrder.orderNumber.match(/EXP-\d{4}-(\d{5})/)
|
||||
if (match) {
|
||||
sequenceNumber = Number.parseInt(match[1], 10) + 1
|
||||
}
|
||||
}
|
||||
|
||||
const orderNumber = `EXP-${year}-${String(sequenceNumber).padStart(5, '0')}`
|
||||
|
||||
// Prepare billing address (exclude saveAddress flag)
|
||||
const billingAddress = {
|
||||
salutation: checkoutData.salutation,
|
||||
firstName: checkoutData.firstName,
|
||||
lastName: checkoutData.lastName,
|
||||
dateOfBirth: checkoutData.dateOfBirth,
|
||||
street: checkoutData.street,
|
||||
postCode: checkoutData.postCode,
|
||||
city: checkoutData.city,
|
||||
countryCode: checkoutData.countryCode,
|
||||
}
|
||||
|
||||
// Create order
|
||||
const [order] = await db
|
||||
.insert(orders)
|
||||
.values({
|
||||
orderNumber,
|
||||
userId: user.id,
|
||||
totalAmount: cartSummary.total.toFixed(2),
|
||||
status: 'pending', // Order starts as pending (awaiting mock payment)
|
||||
billingAddress,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create order items with price snapshots
|
||||
const orderItemsData = cartSummary.items.map((item) => ({
|
||||
orderId: order.id,
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
priceSnapshot: item.product.price, // Snapshot price at time of order
|
||||
productSnapshot: {
|
||||
name: item.product.name,
|
||||
description: item.product.description,
|
||||
navProductId: item.product.navProductId,
|
||||
category: item.product.category,
|
||||
},
|
||||
}))
|
||||
|
||||
await db.insert(orderItems).values(orderItemsData)
|
||||
|
||||
// Optionally save address to user profile
|
||||
if (checkoutData.saveAddress) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
salutation: checkoutData.salutation,
|
||||
dateOfBirth: new Date(checkoutData.dateOfBirth),
|
||||
street: checkoutData.street,
|
||||
postCode: checkoutData.postCode,
|
||||
city: checkoutData.city,
|
||||
countryCode: checkoutData.countryCode,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderId: order.id,
|
||||
orderNumber: order.orderNumber,
|
||||
message: 'Bestellung erfolgreich erstellt',
|
||||
}
|
||||
})
|
||||
87
server/api/payment/mock-paypal.post.ts
Normal file
87
server/api/payment/mock-paypal.post.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* POST /api/payment/mock-paypal
|
||||
*
|
||||
* Mock PayPal payment endpoint for MVP development
|
||||
*
|
||||
* This endpoint simulates a PayPal payment without making actual API calls.
|
||||
* It's used for testing the checkout flow end-to-end before real PayPal integration.
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* orderId: string (UUID)
|
||||
* }
|
||||
*
|
||||
* Behavior:
|
||||
* - Validates order exists and belongs to logged-in user
|
||||
* - Validates order status is 'pending'
|
||||
* - Returns immediate "success" response with mock payment ID
|
||||
* - Does NOT update order status (that happens in /api/orders/confirm/[id])
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* paymentId: string (mock ID),
|
||||
* message: string
|
||||
* }
|
||||
*
|
||||
* Errors:
|
||||
* - 401: Not authenticated
|
||||
* - 400: Invalid request
|
||||
* - 404: Order not found
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { orders } from '../../database/schema'
|
||||
|
||||
const mockPaymentSchema = z.object({
|
||||
orderId: z.string().uuid('Invalid order ID'),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 1. Require authentication
|
||||
const { user } = await requireUserSession(event)
|
||||
|
||||
// 2. Validate request body
|
||||
const body = await readBody(event)
|
||||
const { orderId } = await mockPaymentSchema.parseAsync(body)
|
||||
|
||||
const db = useDatabase()
|
||||
|
||||
// 3. Fetch order
|
||||
const order = await db.query.orders.findFirst({
|
||||
where: and(eq(orders.id, orderId), eq(orders.userId, user.id)),
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Order not found',
|
||||
message: 'Bestellung wurde nicht gefunden',
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Validate order status
|
||||
if (order.status !== 'pending') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid order status',
|
||||
message: `Bestellung kann nicht bezahlt werden. Status: ${order.status}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Simulate PayPal processing delay (optional)
|
||||
// In real implementation, this would be replaced with actual PayPal API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 500)) // 500ms delay
|
||||
|
||||
// 6. Generate mock payment ID
|
||||
const mockPaymentId = `MOCK-PAYPAL-${Date.now()}-${orderId.slice(0, 8)}`
|
||||
|
||||
// 7. Return success response
|
||||
// Note: Order status is NOT updated here. That happens in /api/orders/confirm/[id]
|
||||
return {
|
||||
success: true,
|
||||
paymentId: mockPaymentId,
|
||||
message: 'Mock-Zahlung erfolgreich',
|
||||
}
|
||||
})
|
||||
1
server/database/migrations/0002_eminent_banshee.sql
Normal file
1
server/database/migrations/0002_eminent_banshee.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "products" ADD COLUMN "image_url" text;
|
||||
1
server/database/migrations/0002_heavy_namora.sql
Normal file
1
server/database/migrations/0002_heavy_namora.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "products" ADD COLUMN "image_url" text;
|
||||
1023
server/database/migrations/meta/0002_snapshot.json
Normal file
1023
server/database/migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1762074397305,
|
||||
"tag": "0001_clammy_bulldozer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1762176703220,
|
||||
"tag": "0002_heavy_namora",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -145,6 +145,7 @@ export const products = pgTable(
|
||||
price: decimal('price', { precision: 10, scale: 2 }).notNull(), // EUR with 2 decimal places
|
||||
stockQuantity: integer('stock_quantity').notNull().default(0),
|
||||
category: text('category').notNull(), // e.g., 'makerspace-annual-pass'
|
||||
imageUrl: text('image_url'), // Optional product image URL
|
||||
active: boolean('active').notNull().default(true), // Whether product is available for purchase
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
|
||||
@@ -117,6 +117,7 @@ export async function getCartWithItems(cartId: string): Promise<CartSummary> {
|
||||
active: item.product.active,
|
||||
category: item.product.category,
|
||||
imageUrl: item.product.imageUrl,
|
||||
navProductId: item.product.navProductId,
|
||||
},
|
||||
subtotal: Number.parseFloat(item.product.price) * item.quantity,
|
||||
})
|
||||
|
||||
97
server/utils/schemas/checkout.ts
Normal file
97
server/utils/schemas/checkout.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Checkout Schema
|
||||
*
|
||||
* Validation schema for billing address data during checkout.
|
||||
* All fields are required at checkout (even though optional in users table).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Salutation validation
|
||||
* Maps to: male → HERR, female → FRAU, other → K_ANGABE (X-API format)
|
||||
*/
|
||||
export const salutationEnum = z.enum(['male', 'female', 'other'], {
|
||||
errorMap: () => ({ message: 'Bitte wähle eine Anrede' }),
|
||||
})
|
||||
|
||||
/**
|
||||
* German postal code validation (5 digits)
|
||||
* Format: XXXXX (e.g., 74072, 10115)
|
||||
*/
|
||||
const germanPostCodeRegex = /^\d{5}$/
|
||||
|
||||
/**
|
||||
* Date validation in YYYY-MM-DD format
|
||||
* Must be a valid date in the past (birth date)
|
||||
*/
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
/**
|
||||
* Complete checkout schema
|
||||
*
|
||||
* Validates all billing address fields required for annual pass orders.
|
||||
* These fields are also used for saving to user profile if "save address" checkbox is checked.
|
||||
*/
|
||||
export const checkoutSchema = z.object({
|
||||
// Personal information
|
||||
salutation: salutationEnum,
|
||||
firstName: z
|
||||
.string()
|
||||
.min(2, 'Vorname muss mindestens 2 Zeichen lang sein')
|
||||
.max(100, 'Vorname darf maximal 100 Zeichen lang sein')
|
||||
.trim(),
|
||||
lastName: z
|
||||
.string()
|
||||
.min(2, 'Nachname muss mindestens 2 Zeichen lang sein')
|
||||
.max(100, 'Nachname darf maximal 100 Zeichen lang sein')
|
||||
.trim(),
|
||||
dateOfBirth: z
|
||||
.string()
|
||||
.regex(dateRegex, 'Geburtsdatum muss im Format YYYY-MM-DD sein')
|
||||
.refine(
|
||||
(date) => {
|
||||
const parsed = new Date(date)
|
||||
// Check if date is valid and in the past
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return !isNaN(parsed.getTime()) && parsed < today
|
||||
},
|
||||
{ message: 'Geburtsdatum muss ein gültiges Datum in der Vergangenheit sein' }
|
||||
),
|
||||
|
||||
// Address information
|
||||
street: z
|
||||
.string()
|
||||
.min(3, 'Straße und Hausnummer müssen mindestens 3 Zeichen lang sein')
|
||||
.max(200, 'Straße darf maximal 200 Zeichen lang sein')
|
||||
.trim(),
|
||||
postCode: z
|
||||
.string()
|
||||
.regex(germanPostCodeRegex, 'Postleitzahl muss aus 5 Ziffern bestehen (z.B. 74072)')
|
||||
.trim(),
|
||||
city: z
|
||||
.string()
|
||||
.min(2, 'Stadt muss mindestens 2 Zeichen lang sein')
|
||||
.max(100, 'Stadt darf maximal 100 Zeichen lang sein')
|
||||
.trim(),
|
||||
countryCode: z
|
||||
.string()
|
||||
.length(2, 'Ländercode muss aus 2 Zeichen bestehen (ISO 3166-1 alpha-2)')
|
||||
.toUpperCase()
|
||||
.default('DE'), // Default to Germany
|
||||
|
||||
// Optional: flag to save address to user profile
|
||||
saveAddress: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* TypeScript type inferred from Zod schema
|
||||
*/
|
||||
export type CheckoutData = z.infer<typeof checkoutSchema>
|
||||
|
||||
/**
|
||||
* Billing address type (subset of CheckoutData, without saveAddress flag)
|
||||
* Used for storing in order.billingAddress JSONB field
|
||||
*/
|
||||
export type BillingAddress = Omit<CheckoutData, 'saveAddress'>
|
||||
Reference in New Issue
Block a user