Compare commits
19 Commits
refactor/r
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
623133b450 | ||
|
|
f3932ff7e7 | ||
|
|
5b33ede980 | ||
|
|
99a9787455 | ||
|
|
8ebfb6a6f3 | ||
|
|
bae43c8c78 | ||
|
|
6f9081cb52 | ||
|
|
7f35dc57c6 | ||
|
|
7d63f4fbcd | ||
|
|
b78b081d29 | ||
|
|
676dda4642 | ||
|
|
c8d184f9dc | ||
|
|
322c4c805b | ||
|
|
bcf74e1fd1 | ||
|
|
7ca756fc5a | ||
|
|
ca363a2406 | ||
|
|
5ec0e6c92c | ||
|
|
ee574cb736 | ||
|
|
f66f9b87ab |
367
ONE-PAGE-CHECKOUT-PLAN.md
Normal file
367
ONE-PAGE-CHECKOUT-PLAN.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# One-Page Checkout Implementation Plan
|
||||
|
||||
**Branch:** `feature/one-page-checkout`
|
||||
**Status:** In Development
|
||||
**Priority:** High
|
||||
**Phone Requirement:** Required (not optional)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Convert the current two-phase checkout into a streamlined one-page checkout experience where customers can see all fields at once and complete their order in a single action.
|
||||
|
||||
### Current State
|
||||
- **Phase 1:** Collect email, shipping address → fetch shipping methods
|
||||
- **Phase 2:** Select shipping method, billing address → complete order
|
||||
- **Total API calls:** 6-7 sequential requests across 2 user interactions
|
||||
|
||||
### Target State
|
||||
- **Single Page:** All fields visible simultaneously
|
||||
- **Dynamic updates:** Shipping methods fetch automatically when address changes
|
||||
- **Single submit:** One "Complete Order" button
|
||||
- **Optimized API:** 3-4 sequential steps (parallel where possible)
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Must-Have
|
||||
- [ ] All checkout fields visible on single page
|
||||
- [ ] Phone number is **required** (strict validation)
|
||||
- [ ] Shipping methods fetch automatically (debounced) when address changes
|
||||
- [ ] Real-time total calculation (updates when shipping method selected)
|
||||
- [ ] Single "Complete Order" submit button
|
||||
- [ ] Section-based validation with inline errors
|
||||
- [ ] Auto-scroll to first error on validation failure
|
||||
- [ ] Preserve form data on error
|
||||
|
||||
### UX Requirements
|
||||
- [ ] Clear visual hierarchy (Contact → Shipping → Billing → Shipping Method → Payment)
|
||||
- [ ] Collapsible sections (optional - all expanded by default)
|
||||
- [ ] Loading states for shipping method fetching
|
||||
- [ ] Disabled submit button until all required fields valid
|
||||
- [ ] Success confirmation page (existing)
|
||||
|
||||
### Technical Requirements
|
||||
- [ ] Debounced shipping method API calls (500ms)
|
||||
- [ ] Optimistic UI updates where possible
|
||||
- [ ] Proper error handling per section
|
||||
- [ ] Analytics events for checkout steps
|
||||
- [ ] Mobile-responsive layout
|
||||
|
||||
---
|
||||
|
||||
## UI Layout
|
||||
|
||||
### Left Column (Form - 60% width on desktop)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1. Contact Information │
|
||||
│ ├─ Email * [________________] │
|
||||
│ └─ Phone * [________________] │
|
||||
│ [+381... format hint] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 2. Shipping Address │
|
||||
│ ├─ First Name * [____________] │
|
||||
│ ├─ Last Name * [_____________] │
|
||||
│ ├─ Country * [▼ Serbia ▼] │
|
||||
│ ├─ Street Address * [________] │
|
||||
│ ├─ Apt/Suite [______________] │
|
||||
│ ├─ City * [_________________] │
|
||||
│ └─ Postal Code * [__________] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 3. Billing Address │
|
||||
│ [✓] Same as shipping address │
|
||||
│ (Fields hidden when checked) │
|
||||
├─────────────────────────────────────┤
|
||||
│ 4. Shipping Method │
|
||||
│ (Loading... / Select to see │
|
||||
│ available options) │
|
||||
│ ○ Standard (2-3 days) 400 RSD │
|
||||
│ ○ Express (1-2 days) 800 RSD │
|
||||
├─────────────────────────────────────┤
|
||||
│ 5. Payment Method │
|
||||
│ ● Cash on Delivery │
|
||||
│ (Additional payment methods TBD) │
|
||||
├─────────────────────────────────────┤
|
||||
│ [ Complete Order - 3,600 RSD ] │
|
||||
│ Loading spinner when processing │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Right Column (Order Summary - 40% width on desktop)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Order Summary │
|
||||
├─────────────────────────────────────┤
|
||||
│ Product Image Serum x1 3,200 │
|
||||
│ RSD │
|
||||
├─────────────────────────────────────┤
|
||||
│ Subtotal 3,200 RSD │
|
||||
│ Shipping 400 RSD │
|
||||
│ ───────────────────────────────── │
|
||||
│ Total 3,600 RSD │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Mobile Layout
|
||||
Single column, stacked sections with sticky order summary at bottom.
|
||||
|
||||
---
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
// Form state (existing)
|
||||
const [shippingAddress, setShippingAddress] = useState<AddressForm>({...});
|
||||
const [billingAddress, setBillingAddress] = useState<AddressForm>({...});
|
||||
const [sameAsShipping, setSameAsShipping] = useState(true);
|
||||
|
||||
// New state
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>("cod");
|
||||
const [errors, setErrors] = useState<ValidationErrors>({
|
||||
contact: null,
|
||||
shipping: null,
|
||||
billing: null,
|
||||
shippingMethod: null,
|
||||
general: null,
|
||||
});
|
||||
```
|
||||
|
||||
### Debounced Shipping Method Fetching
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (!isAddressComplete(shippingAddress)) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fetchShippingMethods();
|
||||
}, 500); // 500ms debounce
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [shippingAddress]);
|
||||
```
|
||||
|
||||
### Validation Schema
|
||||
|
||||
```typescript
|
||||
const validationRules = {
|
||||
email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
||||
phone: (value) => {
|
||||
// Country-specific validation
|
||||
// Serbia: +381 XX XXX XXXX
|
||||
// Bosnia: +387 XX XXX XXX
|
||||
// etc.
|
||||
},
|
||||
required: (value) => value.trim().length > 0,
|
||||
postalCode: (value, country) => {
|
||||
// Country-specific postal code validation
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### API Call Sequence
|
||||
|
||||
**Optimized Flow (parallel + sequential):**
|
||||
|
||||
```
|
||||
Step 1: Validation (client-side)
|
||||
├─ Validate all fields
|
||||
└─ Show inline errors
|
||||
|
||||
Step 2: Parallel Independent Calls
|
||||
├─ Update Email
|
||||
└─ Update Shipping Address
|
||||
(Both can run simultaneously)
|
||||
|
||||
Step 3: Conditional Call
|
||||
└─ Update Billing Address (if different from shipping)
|
||||
|
||||
Step 4: Sequential Dependent Calls
|
||||
├─ Update Shipping Method
|
||||
├─ Update Metadata (phone, language, payment method)
|
||||
└─ Complete Checkout
|
||||
|
||||
Total: 4 sequential steps vs current 7+
|
||||
```
|
||||
|
||||
### Error Handling Strategy
|
||||
|
||||
**Field-level:**
|
||||
- Real-time validation on blur
|
||||
- Visual indicators (red border, error message)
|
||||
- Prevent submit if validation fails
|
||||
|
||||
**Section-level:**
|
||||
- Group errors by section
|
||||
- Show section header in red if has errors
|
||||
- Expand section if collapsed and has errors
|
||||
|
||||
**Form-level:**
|
||||
- On submit: validate all fields
|
||||
- If errors: scroll to first error, show summary
|
||||
- If API error: show in relevant section, preserve data
|
||||
|
||||
**API-level:**
|
||||
- Map Saleor errors to form fields when possible
|
||||
- Generic error: show at top of form
|
||||
- Network error: show retry button
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Primary Files
|
||||
|
||||
1. **`/src/app/[locale]/checkout/page.tsx`**
|
||||
- Major refactor of checkout flow
|
||||
- Combine Phase 1 & Phase 2 into single component
|
||||
- Add debounced shipping method fetching
|
||||
- Implement section-based validation
|
||||
- Optimize API call sequence
|
||||
|
||||
2. **`/src/lib/saleor/mutations/Checkout.ts`**
|
||||
- Ensure all mutations available
|
||||
- Add metadata update mutation if needed
|
||||
|
||||
3. **`/src/lib/saleor/queries/Checkout.ts`**
|
||||
- Ensure checkout query returns shipping methods
|
||||
|
||||
### Translation Files
|
||||
|
||||
4. **`/messages/sr.json`** (and other language files)
|
||||
- Add new translation keys for one-page checkout
|
||||
- Section headers
|
||||
- Validation messages
|
||||
- Button labels
|
||||
|
||||
### Styling
|
||||
|
||||
5. **`/src/app/globals.css`** (or Tailwind config)
|
||||
- Ensure consistent form styling
|
||||
- Add validation state styles
|
||||
- Loading spinner styles
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Structure (Day 1-2)
|
||||
- [ ] Refactor checkout page layout
|
||||
- [ ] Display all sections simultaneously
|
||||
- [ ] Keep existing form logic working
|
||||
- [ ] Test existing flow still works
|
||||
|
||||
### Phase 2: Dynamic Shipping Methods (Day 3)
|
||||
- [ ] Implement debounced fetching
|
||||
- [ ] Add loading states
|
||||
- [ ] Display shipping methods inline
|
||||
- [ ] Update total when method selected
|
||||
|
||||
### Phase 3: Validation & Error Handling (Day 4)
|
||||
- [ ] Implement field-level validation
|
||||
- [ ] Add section-based error display
|
||||
- [ ] Auto-scroll to errors
|
||||
- [ ] Test all validation scenarios
|
||||
|
||||
### Phase 4: Optimization (Day 5)
|
||||
- [ ] Optimize API call sequence
|
||||
- [ ] Add parallel mutation execution
|
||||
- [ ] Improve loading states
|
||||
- [ ] Add optimistic updates
|
||||
|
||||
### Phase 5: Polish (Day 6)
|
||||
- [ ] Mobile responsiveness
|
||||
- [ ] Analytics events
|
||||
- [ ] Accessibility improvements
|
||||
- [ ] Final testing
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Functionality Tests
|
||||
- [ ] Fill all fields, submit successfully
|
||||
- [ ] Verify order created in Saleor
|
||||
- [ ] Verify emails sent
|
||||
- [ ] Change shipping method, verify total updates
|
||||
- [ ] Change address, verify shipping methods refetch
|
||||
|
||||
### Validation Tests
|
||||
- [ ] Submit with empty email → email error
|
||||
- [ ] Submit with empty phone → phone error
|
||||
- [ ] Submit with invalid email format → format error
|
||||
- [ ] Submit with invalid phone → format error
|
||||
- [ ] Submit with empty required fields → field errors
|
||||
- [ ] Submit without selecting shipping method → shipping error
|
||||
|
||||
### Edge Cases
|
||||
- [ ] Slow network (test debouncing)
|
||||
- [ ] No shipping methods available
|
||||
- [ ] API failure during submission
|
||||
- [ ] Partial API failure (some mutations succeed)
|
||||
- [ ] Browser refresh (preserve data?)
|
||||
|
||||
### Mobile Tests
|
||||
- [ ] Layout works on iPhone SE
|
||||
- [ ] Layout works on iPhone 14 Pro Max
|
||||
- [ ] Touch targets large enough
|
||||
- [ ] Scroll behavior smooth
|
||||
|
||||
### Accessibility Tests
|
||||
- [ ] Tab navigation works
|
||||
- [ ] Screen reader friendly
|
||||
- [ ] Error announcements
|
||||
- [ ] Focus management
|
||||
|
||||
---
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
1. **Development:** Complete on feature branch
|
||||
2. **Testing:** Local testing with all scenarios
|
||||
3. **Staging:** Deploy to dev.manoonoils.com
|
||||
4. **Monitoring:** Check for errors, conversion rates
|
||||
5. **Production:** Merge to master and deploy
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- **Conversion Rate:** Should increase (fewer steps = less drop-off)
|
||||
- **Time to Complete:** Should decrease (single page vs two phases)
|
||||
- **Error Rate:** Should decrease (better validation)
|
||||
- **Mobile Completion:** Should improve (optimized for mobile)
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Out of Scope)
|
||||
|
||||
- [ ] Save addresses for logged-in users
|
||||
- [ ] Address autocomplete (Google Maps)
|
||||
- [ ] Multiple payment methods (Stripe, etc.)
|
||||
- [ ] Guest checkout improvements
|
||||
- [ ] Order notes/comments field
|
||||
- [ ] Gift wrapping options
|
||||
- [ ] Promo code input
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Phone number is **strictly required** - validate format per country
|
||||
- Keep existing checkout success page
|
||||
- Maintain multi-language support
|
||||
- Ensure analytics tracking works
|
||||
- Don't break existing cart functionality
|
||||
|
||||
---
|
||||
|
||||
**Created:** March 28, 2026
|
||||
**Branch:** feature/one-page-checkout
|
||||
**Next Step:** Start Phase 1 - Core Structure
|
||||
@@ -129,11 +129,78 @@ export default function CheckoutPage() {
|
||||
|
||||
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
||||
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
||||
const [showShippingMethods, setShowShippingMethods] = useState(false);
|
||||
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
|
||||
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
|
||||
// Debounced shipping method fetching
|
||||
useEffect(() => {
|
||||
if (!checkout) return;
|
||||
|
||||
// Check if address is complete enough to fetch shipping methods
|
||||
const isAddressComplete =
|
||||
shippingAddress.firstName &&
|
||||
shippingAddress.lastName &&
|
||||
shippingAddress.streetAddress1 &&
|
||||
shippingAddress.city &&
|
||||
shippingAddress.postalCode &&
|
||||
shippingAddress.country;
|
||||
|
||||
if (!isAddressComplete) {
|
||||
setShippingMethods([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setIsLoadingShipping(true);
|
||||
try {
|
||||
console.log("Fetching shipping methods...");
|
||||
|
||||
// First update the shipping address
|
||||
await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
shippingAddress: {
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
streetAddress1: shippingAddress.streetAddress1,
|
||||
streetAddress2: shippingAddress.streetAddress2,
|
||||
city: shippingAddress.city,
|
||||
postalCode: shippingAddress.postalCode,
|
||||
country: shippingAddress.country,
|
||||
phone: shippingAddress.phone,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Then query for shipping methods
|
||||
const checkoutQueryResult = await saleorClient.query<CheckoutQueryResponse>({
|
||||
query: GET_CHECKOUT_BY_ID,
|
||||
variables: { id: checkout.id },
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
|
||||
const availableMethods = checkoutQueryResult.data?.checkout?.shippingMethods || [];
|
||||
console.log("Available shipping methods:", availableMethods);
|
||||
|
||||
setShippingMethods(availableMethods);
|
||||
|
||||
// Auto-select first method if none selected
|
||||
if (availableMethods.length > 0 && !selectedShippingMethod) {
|
||||
setSelectedShippingMethod(availableMethods[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching shipping methods:", err);
|
||||
} finally {
|
||||
setIsLoadingShipping(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkout, shippingAddress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkout) {
|
||||
refreshCheckout();
|
||||
@@ -189,25 +256,56 @@ export default function CheckoutPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all required fields
|
||||
if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
|
||||
setError(t("errorEmailRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode || !shippingAddress.phone) {
|
||||
if (!shippingAddress.phone || shippingAddress.phone.length < 8) {
|
||||
setError(t("errorPhoneRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode) {
|
||||
setError(t("errorFieldsRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedShippingMethod) {
|
||||
setError(t("errorSelectShipping"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// If we're showing shipping methods and one is selected, complete the order
|
||||
if (showShippingMethods && selectedShippingMethod) {
|
||||
console.log("Phase 2: Completing order with shipping method...");
|
||||
console.log("Completing order...");
|
||||
|
||||
console.log("Step 1: Updating billing address...");
|
||||
console.log("Step 1: Updating email...");
|
||||
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
|
||||
mutation: CHECKOUT_EMAIL_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
email: shippingAddress.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
||||
const errorMessage = emailResult.data.checkoutEmailUpdate.errors[0].message;
|
||||
if (errorMessage.includes("Couldn't resolve to a node")) {
|
||||
console.error("Checkout not found, clearing cart...");
|
||||
localStorage.removeItem('cart');
|
||||
localStorage.removeItem('checkoutId');
|
||||
window.location.href = `/${locale}/products`;
|
||||
return;
|
||||
}
|
||||
throw new Error(`Email update failed: ${errorMessage}`);
|
||||
}
|
||||
console.log("Step 1: Email updated successfully");
|
||||
|
||||
console.log("Step 2: Updating billing address...");
|
||||
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
@@ -228,9 +326,9 @@ export default function CheckoutPage() {
|
||||
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
||||
throw new Error(`Billing address update failed: ${billingResult.data.checkoutBillingAddressUpdate.errors[0].message}`);
|
||||
}
|
||||
console.log("Step 1: Billing address updated successfully");
|
||||
console.log("Step 2: Billing address updated successfully");
|
||||
|
||||
console.log("Step 2: Setting shipping method...");
|
||||
console.log("Step 3: Setting shipping method...");
|
||||
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
||||
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||
variables: {
|
||||
@@ -242,9 +340,9 @@ export default function CheckoutPage() {
|
||||
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
||||
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
|
||||
}
|
||||
console.log("Step 2: Shipping method set successfully");
|
||||
console.log("Step 3: Shipping method set successfully");
|
||||
|
||||
console.log("Step 3: Saving metadata...");
|
||||
console.log("Step 4: Saving metadata...");
|
||||
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
||||
mutation: CHECKOUT_METADATA_UPDATE,
|
||||
variables: {
|
||||
@@ -261,10 +359,10 @@ export default function CheckoutPage() {
|
||||
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
|
||||
console.warn("Failed to save phone metadata:", metadataResult.data.updateMetadata.errors);
|
||||
} else {
|
||||
console.log("Step 3: Phone number saved successfully");
|
||||
console.log("Step 4: Phone number saved successfully");
|
||||
}
|
||||
|
||||
console.log("Step 4: Completing checkout...");
|
||||
console.log("Step 5: Completing checkout...");
|
||||
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
||||
mutation: CHECKOUT_COMPLETE,
|
||||
variables: {
|
||||
@@ -304,93 +402,6 @@ export default function CheckoutPage() {
|
||||
} else {
|
||||
throw new Error(t("errorCreatingOrder"));
|
||||
}
|
||||
} else {
|
||||
// Phase 1: Update email and address, then fetch shipping methods
|
||||
console.log("Phase 1: Updating email and address...");
|
||||
|
||||
console.log("Step 1: Updating email...");
|
||||
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
|
||||
mutation: CHECKOUT_EMAIL_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
email: shippingAddress.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
||||
const errorMessage = emailResult.data.checkoutEmailUpdate.errors[0].message;
|
||||
// Check if checkout no longer exists
|
||||
if (errorMessage.includes("Couldn't resolve to a node")) {
|
||||
console.error("Checkout not found, clearing cart...");
|
||||
localStorage.removeItem('cart');
|
||||
localStorage.removeItem('checkoutId');
|
||||
window.location.href = `/${locale}/products`;
|
||||
return;
|
||||
}
|
||||
throw new Error(`Email update failed: ${errorMessage}`);
|
||||
}
|
||||
console.log("Step 1: Email updated successfully");
|
||||
|
||||
console.log("Step 2: Updating shipping address...");
|
||||
console.log("Shipping address data:", {
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
streetAddress1: shippingAddress.streetAddress1,
|
||||
city: shippingAddress.city,
|
||||
postalCode: shippingAddress.postalCode,
|
||||
country: shippingAddress.country,
|
||||
phone: shippingAddress.phone,
|
||||
});
|
||||
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
shippingAddress: {
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
streetAddress1: shippingAddress.streetAddress1,
|
||||
streetAddress2: shippingAddress.streetAddress2,
|
||||
city: shippingAddress.city,
|
||||
postalCode: shippingAddress.postalCode,
|
||||
country: shippingAddress.country,
|
||||
phone: shippingAddress.phone,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
||||
throw new Error(`Shipping address update failed: ${shippingResult.data.checkoutShippingAddressUpdate.errors[0].message}`);
|
||||
}
|
||||
console.log("Step 2: Shipping address updated successfully");
|
||||
|
||||
// Query for checkout to get available shipping methods
|
||||
console.log("Step 3: Fetching shipping methods...");
|
||||
const checkoutQueryResult = await saleorClient.query<CheckoutQueryResponse>({
|
||||
query: GET_CHECKOUT_BY_ID,
|
||||
variables: {
|
||||
id: checkout.id,
|
||||
},
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
|
||||
const availableMethods = checkoutQueryResult.data?.checkout?.shippingMethods || [];
|
||||
console.log("Available shipping methods:", availableMethods);
|
||||
|
||||
if (availableMethods.length === 0) {
|
||||
throw new Error(t("errorNoShippingMethods"));
|
||||
}
|
||||
|
||||
setShippingMethods(availableMethods);
|
||||
setShowShippingMethods(true);
|
||||
|
||||
// Track shipping step
|
||||
trackCheckoutStep("shipping_method_selection", {
|
||||
available_methods_count: availableMethods.length,
|
||||
});
|
||||
|
||||
// Don't complete yet - show shipping method selection
|
||||
console.log("Phase 1 complete. Waiting for shipping method selection...");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("Checkout error:", err);
|
||||
|
||||
@@ -606,9 +617,17 @@ export default function CheckoutPage() {
|
||||
</div>
|
||||
|
||||
{/* Shipping Method Selection */}
|
||||
{showShippingMethods && shippingMethods.length > 0 && (
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
|
||||
{isLoadingShipping ? (
|
||||
<div className="flex items-center gap-2 text-foreground-muted">
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{t("loadingShippingMethods")}</span>
|
||||
</div>
|
||||
) : shippingMethods.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{shippingMethods.map((method) => (
|
||||
<label
|
||||
@@ -636,18 +655,17 @@ export default function CheckoutPage() {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{!selectedShippingMethod && (
|
||||
<p className="text-red-500 text-sm mt-2">{t("errorSelectShipping")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<p className="text-foreground-muted">{t("enterAddressForShipping")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || lines.length === 0 || (showShippingMethods && !selectedShippingMethod)}
|
||||
disabled={isLoading || lines.length === 0 || !selectedShippingMethod}
|
||||
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? t("processing") : showShippingMethods ? t("completeOrder", { total: formatPrice(total) }) : t("continueToShipping")}
|
||||
{isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -691,6 +709,12 @@ export default function CheckoutPage() {
|
||||
<span className="text-foreground-muted">{t("subtotal")}</span>
|
||||
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
|
||||
</div>
|
||||
{selectedShippingMethod && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground-muted">{t("shipping")}</span>
|
||||
<span>{formatPrice(shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount || 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
|
||||
<span>{t("total")}</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
|
||||
263
src/app/[locale]/not-found.tsx
Normal file
263
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getProducts, getProductImage, getProductPrice } from "@/lib/saleor";
|
||||
import {
|
||||
getSaleorLocale,
|
||||
isValidLocale,
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_COOKIE,
|
||||
type Locale
|
||||
} from "@/lib/i18n/locales";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Star, ArrowRight } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page Not Found | ManoonOils",
|
||||
description: "Discover our bestselling natural oils for hair and skin care.",
|
||||
};
|
||||
|
||||
interface NotFoundProps {
|
||||
params?: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
function detectLocaleFromURL(pathname: string): Locale {
|
||||
const match = pathname.match(/^\/(sr|en|de|fr)(?:\/|$)/);
|
||||
if (match && isValidLocale(match[1])) {
|
||||
return match[1];
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export default async function NotFound({ params }: NotFoundProps) {
|
||||
const headersList = await headers();
|
||||
const pathname = headersList.get("x-invoke-path") || headersList.get("x-matched-path") || "/";
|
||||
|
||||
// Try to get locale from params first, then detect from URL
|
||||
let locale: string;
|
||||
|
||||
if (params) {
|
||||
const paramsData = await params;
|
||||
locale = paramsData.locale;
|
||||
} else {
|
||||
// Detect from URL path
|
||||
locale = detectLocaleFromURL(pathname);
|
||||
}
|
||||
|
||||
// Validate locale
|
||||
const validLocale: Locale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
|
||||
const t = await getTranslations({ locale: validLocale, namespace: "NotFound" });
|
||||
const productReviewT = await getTranslations({ locale: validLocale, namespace: "ProductReviews" });
|
||||
|
||||
const saleorLocale = getSaleorLocale(validLocale);
|
||||
|
||||
// Fetch products
|
||||
let products: Product[] = [];
|
||||
try {
|
||||
products = await getProducts(saleorLocale);
|
||||
} catch (error) {
|
||||
console.error("Error fetching products for 404 page:", error);
|
||||
}
|
||||
|
||||
// Get first 4 products as bestsellers
|
||||
const bestsellers = products.slice(0, 4);
|
||||
|
||||
// Get product reviews
|
||||
const productReviews = productReviewT.raw("reviews") as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
location: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
}>;
|
||||
|
||||
// Get urgency messages
|
||||
const urgencyMessages = [
|
||||
t("urgency1"),
|
||||
t("urgency2"),
|
||||
t("urgency3", { amount: "3,000" }),
|
||||
t("urgency4"),
|
||||
t("urgency5"),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="pt-24 pb-12 px-4 sm:px-6 lg:px-8 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-serif italic text-[#1A1A1A] mb-4 leading-tight">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-xl sm:text-2xl text-[#666666] mb-8 font-light">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={`/${validLocale}/products`}
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-[#1A1A1A] text-white text-sm uppercase tracking-[0.15em] font-medium hover:bg-[#333333] transition-colors duration-300"
|
||||
>
|
||||
{t("shopBestsellers")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Urgency Messages */}
|
||||
<div className="bg-gradient-to-r from-amber-50 to-orange-50 border-y border-amber-100 py-3">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-sm sm:text-base text-amber-800 font-medium">
|
||||
{urgencyMessages[0]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bestsellers Section */}
|
||||
<section className="py-16 sm:py-20 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-xs tracking-[0.3em] uppercase text-[#6B7280] mb-4 block">
|
||||
Popular
|
||||
</span>
|
||||
<h2 className="font-serif italic text-3xl sm:text-4xl lg:text-5xl text-[#1A1A1A] tracking-tight">
|
||||
{t("bestsellersTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{bestsellers.length > 0 ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
|
||||
{bestsellers.map((product) => {
|
||||
const image = getProductImage(product);
|
||||
const price = getProductPrice(product);
|
||||
const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
|
||||
|
||||
return (
|
||||
<div key={product.id} className="group">
|
||||
<Link href={`/${validLocale}/products/${product.slug}`} className="block">
|
||||
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
sizes="(max-width: 768px) 50vw, 25vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
|
||||
<span className="text-sm">No Image</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAvailable && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center">
|
||||
<span className="text-sm uppercase tracking-[0.1em] text-[#666666]">
|
||||
Out of Stock
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-full group-hover:translate-y-0 transition-transform duration-300">
|
||||
<div className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] text-center">
|
||||
View Product
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-[15px] font-medium text-[#1a1a1a] mb-1 group-hover:text-[#666666] transition-colors line-clamp-1">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-[14px] text-[#666666]">
|
||||
{price || "Contact for price"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-[#666666]">No products available at the moment.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<section className="py-16 sm:py-20 px-4 sm:px-6 lg:px-8 bg-[#F0F7FA]">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-xs tracking-[0.3em] uppercase text-[#6B7280] mb-4 block">
|
||||
Testimonials
|
||||
</span>
|
||||
<h2 className="font-serif italic text-3xl sm:text-4xl lg:text-5xl text-[#1A1A1A] tracking-tight">
|
||||
{t("testimonialsTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{productReviews.slice(0, 6).map((review) => (
|
||||
<div key={review.id} className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<div className="flex gap-1 mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < review.rating
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "fill-gray-200 text-gray-200"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[#1A1A1A] mb-4 text-sm leading-relaxed">
|
||||
“{review.text}”
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-[#c9a962] to-[#e8967a] flex items-center justify-center text-white font-medium text-sm">
|
||||
{review.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[#1A1A1A] text-sm">{review.name}</p>
|
||||
<p className="text-xs text-[#6B7280]">{review.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA Section */}
|
||||
<section className="py-16 sm:py-24 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div>
|
||||
<h2 className="font-serif italic text-3xl sm:text-4xl lg:text-5xl text-[#1A1A1A] mb-4 tracking-tight">
|
||||
{t("finalCTATitle")}
|
||||
</h2>
|
||||
<p className="text-lg sm:text-xl text-[#666666] mb-8 font-light">
|
||||
{t("finalCTASubtitle")}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={`/${validLocale}/products`}
|
||||
className="inline-flex items-center gap-2 px-10 py-4 bg-[#1A1A1A] text-white text-sm uppercase tracking-[0.15em] font-medium hover:bg-[#333333] transition-colors duration-300"
|
||||
>
|
||||
{t("viewAllProducts")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/404/UrgencyMessages.tsx
Normal file
45
src/components/404/UrgencyMessages.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function UrgencyMessages() {
|
||||
const t = useTranslations("NotFound");
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const messages = [
|
||||
t("urgency1"),
|
||||
t("urgency2"),
|
||||
t("urgency3", { amount: "3,000" }),
|
||||
t("urgency4"),
|
||||
t("urgency5"),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % messages.length);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [messages.length]);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-amber-50 to-orange-50 border-y border-amber-100 py-3 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.p
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center text-sm sm:text-base text-amber-800 font-medium"
|
||||
>
|
||||
{messages[currentIndex]}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -30,14 +30,16 @@ export default function CartDrawer() {
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
const lineCount = getLineCount();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) {
|
||||
if (!initializedRef.current && locale) {
|
||||
// Set language code before initializing checkout
|
||||
useSaleorCheckoutStore.getState().setLanguageCode(locale);
|
||||
initCheckout();
|
||||
setInitialized(true);
|
||||
initializedRef.current = true;
|
||||
}
|
||||
}, [initialized]);
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
|
||||
@@ -15,9 +15,13 @@ interface NewHeroProps {
|
||||
|
||||
export default function NewHero({ featuredProduct }: NewHeroProps) {
|
||||
const locale = useLocale();
|
||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
// Set language code before adding to cart
|
||||
if (locale) {
|
||||
setLanguageCode(locale);
|
||||
}
|
||||
const variant = featuredProduct?.variants?.[0];
|
||||
if (variant?.id) {
|
||||
await addLine(variant.id, 1);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
|
||||
import CartDrawer from "@/components/cart/CartDrawer";
|
||||
@@ -16,14 +16,15 @@ interface HeaderProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
export default function Header({ locale: propLocale = "sr" }: HeaderProps) {
|
||||
const t = useTranslations("Header");
|
||||
const pathname = usePathname();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
|
||||
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
|
||||
const { getLineCount, toggleCart, initCheckout, setLanguageCode } = useSaleorCheckoutStore();
|
||||
const locale = useLocale();
|
||||
|
||||
const itemCount = getLineCount();
|
||||
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
|
||||
@@ -54,9 +55,14 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
setLangDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Set language code first, then initialize checkout
|
||||
useEffect(() => {
|
||||
initCheckout();
|
||||
}, [initCheckout]);
|
||||
if (locale) {
|
||||
setLanguageCode(locale);
|
||||
// Initialize checkout after language code is set
|
||||
initCheckout();
|
||||
}
|
||||
}, [locale, setLanguageCode, initCheckout]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
||||
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
|
||||
const { trackProductView, trackAddToCart } = useAnalytics();
|
||||
const validLocale = isValidLocale(locale) ? locale : "sr";
|
||||
|
||||
@@ -147,6 +147,11 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
||||
const handleAddToCart = async () => {
|
||||
if (!selectedVariantId) return;
|
||||
|
||||
// Set language code before adding to cart
|
||||
if (validLocale) {
|
||||
setLanguageCode(validLocale);
|
||||
}
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await addLine(selectedVariantId, 1);
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
||||
"errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||
"errorFieldsRequired": "Bitte füllen Sie alle erforderlichen Felder aus.",
|
||||
"errorNoShippingMethods": "Keine Versandmethoden für diese Adresse verfügbar. Bitte überprüfen Sie Ihre Adresse oder kontaktieren Sie den Support.",
|
||||
"errorSelectShipping": "Bitte wählen Sie eine Versandmethode.",
|
||||
"errorPhoneRequired": "Bitte geben Sie eine gültige Telefonnummer ein.",
|
||||
"loadingShippingMethods": "Versandoptionen werden geladen...",
|
||||
"enterAddressForShipping": "Geben Sie Ihre Adresse ein, um Versandoptionen zu sehen.",
|
||||
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
|
||||
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
||||
"orderConfirmed": "Bestellung bestätigt!",
|
||||
@@ -379,5 +384,20 @@
|
||||
"orderNumber": "Bestellnummer",
|
||||
"confirmationEmail": "Sie erhalten in Kürze eine Bestätigungs-E-Mail. Wir werden Sie kontaktieren, um Nachnahme zu arrangieren.",
|
||||
"continueShoppingBtn": "Weiter einkaufen"
|
||||
},
|
||||
"NotFound": {
|
||||
"title": "Wir konnten diese Seite nicht finden...",
|
||||
"subtitle": "...aber wir haben etwas Besseres für Sie gefunden",
|
||||
"shopBestsellers": "Bestseller Shoppen",
|
||||
"bestsellersTitle": "Unsere Bestseller Shoppen",
|
||||
"testimonialsTitle": "Schließen Sie sich 50.000+ zufriedenen Kunden an",
|
||||
"finalCTATitle": "Bereit, Ihre Reise zu beginnen?",
|
||||
"finalCTASubtitle": "Entdecken Sie den natürlichen Unterschied",
|
||||
"viewAllProducts": "Alle Produkte Shoppen",
|
||||
"urgency1": "🔥 500+ Menschen kaufen gerade unsere Bestseller",
|
||||
"urgency2": "⚡ Nur noch 50 Flaschen unseres beliebtesten Serums",
|
||||
"urgency3": "📦 Kostenloser Versand bei Bestellungen über {amount}",
|
||||
"urgency4": "⭐ 4,9/5 Bewertung von 50.000+ zufriedenen Kunden",
|
||||
"urgency5": "🚀 Bestellungen werden innerhalb von 24 Stunden versandt"
|
||||
}
|
||||
}
|
||||
@@ -420,6 +420,9 @@
|
||||
"errorFieldsRequired": "Please fill in all required fields.",
|
||||
"errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.",
|
||||
"errorSelectShipping": "Please select a shipping method.",
|
||||
"errorPhoneRequired": "Please enter a valid phone number.",
|
||||
"loadingShippingMethods": "Loading shipping options...",
|
||||
"enterAddressForShipping": "Enter your address to see shipping options.",
|
||||
"errorOccurred": "An error occurred during checkout.",
|
||||
"errorCreatingOrder": "Failed to create order.",
|
||||
"continueToShipping": "Continue to Shipping",
|
||||
@@ -428,5 +431,20 @@
|
||||
"orderNumber": "Order Number",
|
||||
"confirmationEmail": "You will receive a confirmation email shortly. We will contact you to arrange Cash on Delivery.",
|
||||
"continueShoppingBtn": "Continue Shopping"
|
||||
},
|
||||
"NotFound": {
|
||||
"title": "We couldn't find that page...",
|
||||
"subtitle": "...but we found something better for you",
|
||||
"shopBestsellers": "Shop Bestsellers",
|
||||
"bestsellersTitle": "Shop Our Bestsellers",
|
||||
"testimonialsTitle": "Join 50,000+ Happy Customers",
|
||||
"finalCTATitle": "Ready to Start Your Journey?",
|
||||
"finalCTASubtitle": "Discover the natural difference",
|
||||
"viewAllProducts": "Shop All Products",
|
||||
"urgency1": "🔥 500+ people are shopping our bestsellers right now",
|
||||
"urgency2": "⚡ Only 50 bottles left of our most popular serum",
|
||||
"urgency3": "📦 Free shipping on orders over {amount}",
|
||||
"urgency4": "⭐ 4.9/5 rating from 50,000+ happy customers",
|
||||
"urgency5": "🚀 Orders ship within 24 hours"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +372,11 @@
|
||||
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
||||
"errorEmailRequired": "Veuillez entrer une adresse e-mail valide.",
|
||||
"errorFieldsRequired": "Veuillez remplir tous les champs obligatoires.",
|
||||
"errorNoShippingMethods": "Aucune méthode de livraison disponible pour cette adresse. Veuillez vérifier votre adresse ou contacter le support.",
|
||||
"errorSelectShipping": "Veuillez sélectionner une méthode de livraison.",
|
||||
"errorPhoneRequired": "Veuillez entrer un numéro de téléphone valide.",
|
||||
"loadingShippingMethods": "Chargement des options de livraison...",
|
||||
"enterAddressForShipping": "Entrez votre adresse pour voir les options de livraison.",
|
||||
"errorOccurred": "Une erreur s'est produite lors du paiement.",
|
||||
"errorCreatingOrder": "Échec de la création de la commande.",
|
||||
"orderConfirmed": "Commande Confirmée!",
|
||||
@@ -379,5 +384,20 @@
|
||||
"orderNumber": "Numéro de Commande",
|
||||
"confirmationEmail": "Vous recevrez bientôt un email de confirmation. Nous vous contacterons pour organiser le paiement contre-remboursement.",
|
||||
"continueShoppingBtn": "Continuer les Achats"
|
||||
},
|
||||
"NotFound": {
|
||||
"title": "Nous n'avons pas trouvé cette page...",
|
||||
"subtitle": "...mais nous avons trouvé quelque chose de mieux pour vous",
|
||||
"shopBestsellers": "Acheter les Best-sellers",
|
||||
"bestsellersTitle": "Acheter Nos Best-sellers",
|
||||
"testimonialsTitle": "Rejoignez 50 000+ Clients Satisfaits",
|
||||
"finalCTATitle": "Prêt à Commencer Votre Voyage?",
|
||||
"finalCTASubtitle": "Découvrez la différence naturelle",
|
||||
"viewAllProducts": "Voir Tous les Produits",
|
||||
"urgency1": "🔥 500+ personnes achètent nos best-sellers en ce moment",
|
||||
"urgency2": "⚡ Plus que 50 bouteilles de notre sérum le plus populaire",
|
||||
"urgency3": "📦 Livraison gratuite pour les commandes supérieures à {amount}",
|
||||
"urgency4": "⭐ Note de 4,9/5 de 50 000+ clients satisfaits",
|
||||
"urgency5": "🚀 Les commandes sont expédiées sous 24 heures"
|
||||
}
|
||||
}
|
||||
@@ -418,6 +418,11 @@
|
||||
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
||||
"errorEmailRequired": "Molimo unesite validnu email adresu.",
|
||||
"errorFieldsRequired": "Molimo popunite sva obavezna polja.",
|
||||
"errorNoShippingMethods": "Nema dostupnih načina dostave za ovu adresu. Molimo proverite adresu ili kontaktirajte podršku.",
|
||||
"errorSelectShipping": "Molimo izaberite način dostave.",
|
||||
"errorPhoneRequired": "Molimo unesite validan broj telefona.",
|
||||
"loadingShippingMethods": "Učitavanje opcija dostave...",
|
||||
"enterAddressForShipping": "Unesite adresu da vidite opcije dostave.",
|
||||
"errorOccurred": "Došlo je do greške prilikom kupovine.",
|
||||
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
||||
"orderConfirmed": "Narudžbina potvrđena!",
|
||||
@@ -425,5 +430,20 @@
|
||||
"orderNumber": "Broj narudžbine",
|
||||
"confirmationEmail": "Uскoro ćete primiti email potvrde. Kontaktiraćemo vas da dogovorimo pouzećem plaćanje.",
|
||||
"continueShoppingBtn": "Nastavi kupovinu"
|
||||
},
|
||||
"NotFound": {
|
||||
"title": "Nismo mogli da pronađemo tu stranicu...",
|
||||
"subtitle": "...ali smo pronašli nešto bolje za vas",
|
||||
"shopBestsellers": "Kupi Najprodavanije",
|
||||
"bestsellersTitle": "Kupi Naše Najprodavanije Proizvode",
|
||||
"testimonialsTitle": "Pridruži se 50,000+ Zadovoljnih Kupaca",
|
||||
"finalCTATitle": "Spremni da Zapocnete Svoje Putovanje?",
|
||||
"finalCTASubtitle": "Otkrijte prirodnu razliku",
|
||||
"viewAllProducts": "Pogledaj Sve Proizvode",
|
||||
"urgency1": "🔥 500+ ljudi kupuje naše najprodavanije proizvode upravo sada",
|
||||
"urgency2": "⚡ Preostalo samo 50 bočica našeg najpopularnijeg seruma",
|
||||
"urgency3": "📦 Besplatna dostava za porudžbine preko {amount} RSD",
|
||||
"urgency4": "⭐ Ocena 4.9/5 od 50,000+ zadovoljnih kupaca",
|
||||
"urgency5": "🚀 Porudžbine se šalju u roku od 24 sata"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,22 +58,24 @@ interface GetCheckoutResponse {
|
||||
interface SaleorCheckoutStore {
|
||||
checkout: Checkout | null;
|
||||
checkoutToken: string | null;
|
||||
languageCode: string | null;
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
|
||||
// Actions
|
||||
initCheckout: () => Promise<void>;
|
||||
addLine: (variantId: string, quantity: number) => Promise<void>;
|
||||
updateLine: (lineId: string, quantity: number) => Promise<void>;
|
||||
removeLine: (lineId: string) => Promise<void>;
|
||||
setEmail: (email: string) => Promise<void>;
|
||||
setLanguageCode: (languageCode: string) => void;
|
||||
refreshCheckout: () => Promise<void>;
|
||||
toggleCart: () => void;
|
||||
openCart: () => void;
|
||||
closeCart: () => void;
|
||||
clearError: () => void;
|
||||
|
||||
|
||||
// Getters
|
||||
getLineCount: () => number;
|
||||
getTotal: () => number;
|
||||
@@ -85,13 +87,14 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
(set, get) => ({
|
||||
checkout: null,
|
||||
checkoutToken: null,
|
||||
languageCode: null,
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
initCheckout: async () => {
|
||||
const { checkoutToken } = get();
|
||||
|
||||
const { checkoutToken, languageCode } = get();
|
||||
|
||||
if (checkoutToken) {
|
||||
// Try to fetch existing checkout
|
||||
try {
|
||||
@@ -99,7 +102,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
query: GET_CHECKOUT,
|
||||
variables: { token: checkoutToken },
|
||||
});
|
||||
|
||||
|
||||
if (data?.checkout) {
|
||||
set({ checkout: data.checkout });
|
||||
return;
|
||||
@@ -108,8 +111,8 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
// Checkout not found or expired, create new one
|
||||
}
|
||||
}
|
||||
|
||||
// Create new checkout
|
||||
|
||||
// Create new checkout with language code
|
||||
try {
|
||||
const { data } = await saleorClient.mutate<CheckoutCreateResponse>({
|
||||
mutation: CHECKOUT_CREATE,
|
||||
@@ -117,10 +120,11 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
input: {
|
||||
channel: CHANNEL,
|
||||
lines: [],
|
||||
languageCode: languageCode ? languageCode.toUpperCase() : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (data?.checkoutCreate?.checkout) {
|
||||
set({
|
||||
checkout: data.checkoutCreate.checkout,
|
||||
@@ -294,6 +298,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
openCart: () => set({ isOpen: true }),
|
||||
closeCart: () => set({ isOpen: false }),
|
||||
clearError: () => set({ error: null }),
|
||||
setLanguageCode: (languageCode: string) => set({ languageCode }),
|
||||
|
||||
getLineCount: () => {
|
||||
const { checkout } = get();
|
||||
|
||||
Reference in New Issue
Block a user