This commit is contained in:
root
2025-12-22 06:43:19 +01:00
parent a940d51475
commit 6f4ca75faf
25 changed files with 1350 additions and 221 deletions

51
lib/currency.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Currency conversion utilities
* Database stores prices in EUR
* Countries in CHF_COUNTRIES see CHF (converted from EUR)
* All other countries see EUR
*/
// List of country codes that use CHF currency
// Add or remove country codes here to change which countries get CHF pricing
export const CHF_COUNTRIES = ['CH'] as const
// EUR to CHF exchange rate
// Using a fixed rate - in production, you might want to fetch this from an API
// Current approximate rate: 1 EUR ≈ 0.97 CHF (as of 2025)
// Note: This is approximate. For production, consider using a real-time exchange rate API
const EUR_TO_CHF_RATE = 0.97
/**
* Convert EUR amount to CHF
*/
export function convertEurToChf(eurAmount: number): number {
return eurAmount * EUR_TO_CHF_RATE
}
/**
* Get the currency to use based on country code
* Returns 'CHF' for countries in CHF_COUNTRIES, 'EUR' for all other countries
*/
export function getCurrencyForCountry(countryCode: string | null): 'CHF' | 'EUR' {
return countryCode && CHF_COUNTRIES.includes(countryCode as any) ? 'CHF' : 'EUR'
}
/**
* Convert price based on country
* If country is in CHF_COUNTRIES, convert EUR to CHF
* Otherwise, return EUR amount as-is
*/
export function convertPriceForCountry(priceInEur: number, countryCode: string | null): number {
if (countryCode && CHF_COUNTRIES.includes(countryCode as any)) {
return convertEurToChf(priceInEur)
}
return priceInEur
}
/**
* Get currency symbol for display
*/
export function getCurrencySymbol(currency: 'CHF' | 'EUR'): string {
return currency === 'CHF' ? 'CHF' : 'EUR'
}

View File

@@ -7,8 +7,10 @@ const pool = mysql.createPool({
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'cbd420',
waitForConnections: true,
connectionLimit: 10,
connectionLimit: 50, // Increased from 10 to handle more concurrent requests
queueLimit: 0,
connectTimeout: 60000, // 60 seconds timeout for establishing connection
idleTimeout: 600000, // 10 minutes - close idle connections after this time
})
export default pool

92
lib/geolocation.ts Normal file
View File

@@ -0,0 +1,92 @@
import { NextRequest } from 'next/server'
import { CHF_COUNTRIES } from '@/lib/currency'
/**
* Get client IP address from request headers
*/
function getClientIp(request: NextRequest): string | null {
// Check various headers that might contain the real IP
const forwardedFor = request.headers.get('x-forwarded-for')
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs, take the first one
return forwardedFor.split(',')[0].trim()
}
const realIp = request.headers.get('x-real-ip')
if (realIp) {
return realIp.trim()
}
const cfConnectingIp = request.headers.get('cf-connecting-ip') // Cloudflare
if (cfConnectingIp) {
return cfConnectingIp.trim()
}
// Fallback to remote address if available
const remoteAddress = request.headers.get('remote-addr')
if (remoteAddress) {
return remoteAddress.trim()
}
return null
}
/**
* Get country code from IP address using ip-api.com (free, no API key required)
* Returns country code (e.g., 'CH' for Switzerland, 'SG' for Singapore), or null if detection fails
*/
export async function getCountryFromIp(request: NextRequest): Promise<string | null> {
try {
const ip = getClientIp(request)
if (!ip) {
console.warn('Could not determine client IP')
return null
}
// Skip localhost/private IPs
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.')) {
// For local development, default to Switzerland
return 'CH'
}
// Use ip-api.com free service (no API key required, rate limited)
// Returns JSON with country code
const response = await fetch(`http://ip-api.com/json/${ip}?fields=countryCode`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
if (!response.ok) {
console.warn(`Failed to fetch geolocation: ${response.status}`)
return null
}
const data = await response.json()
if (data.countryCode) {
return data.countryCode
}
return null
} catch (error) {
console.error('Error detecting country from IP:', error)
return null
}
}
/**
* Calculate shipping fee based on country
* Returns shipping fee in the appropriate currency
* 15 CHF for countries in CHF_COUNTRIES, 40 EUR for all other countries
*/
export function calculateShippingFee(countryCode: string | null): number {
// 15 CHF for countries in CHF_COUNTRIES, 40 EUR for all other countries
if (countryCode && CHF_COUNTRIES.includes(countryCode as any)) {
return 15
}
return 40
}

109
lib/i18n.tsx Normal file
View File

@@ -0,0 +1,109 @@
'use client'
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import enTranslations from './translations/en.json'
import deTranslations from './translations/de.json'
type Language = 'en' | 'de'
type TranslationKey = string
type Translations = typeof enTranslations
interface I18nContextType {
language: Language
setLanguage: (lang: Language) => void
t: (key: TranslationKey, params?: Record<string, string | number>) => string
}
const I18nContext = createContext<I18nContextType | undefined>(undefined)
const translations: Record<Language, Translations> = {
en: enTranslations,
de: deTranslations,
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>('en')
// Load language from localStorage on mount
useEffect(() => {
const savedLanguage = localStorage.getItem('language') as Language
if (savedLanguage && (savedLanguage === 'en' || savedLanguage === 'de')) {
setLanguageState(savedLanguage)
} else {
// Detect browser language
const browserLang = navigator.language.split('-')[0]
if (browserLang === 'de') {
setLanguageState('de')
} else {
setLanguageState('en')
}
}
}, [])
const setLanguage = (lang: Language) => {
setLanguageState(lang)
localStorage.setItem('language', lang)
// Update HTML lang attribute
if (typeof document !== 'undefined') {
document.documentElement.lang = lang
}
}
const t = (key: TranslationKey, params?: Record<string, string | number>): string => {
const keys = key.split('.')
let value: any = translations[language]
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k]
} else {
// Fallback to English if key not found
value = translations.en
for (const fallbackKey of keys) {
if (value && typeof value === 'object' && fallbackKey in value) {
value = value[fallbackKey]
} else {
return key // Return key if translation not found
}
}
break
}
}
if (typeof value !== 'string') {
return key
}
// Replace parameters in the translation string
if (params) {
return value.replace(/\{(\w+)\}/g, (match, paramKey) => {
return params[paramKey]?.toString() || match
})
}
return value
}
// Update HTML lang attribute when language changes
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.lang = language
}
}, [language])
return (
<I18nContext.Provider value={{ language, setLanguage, t }}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const context = useContext(I18nContext)
if (context === undefined) {
throw new Error('useI18n must be used within an I18nProvider')
}
return context
}

211
lib/translations/de.json Normal file
View File

@@ -0,0 +1,211 @@
{
"common": {
"loading": "Lädt...",
"error": "Ein Fehler ist aufgetreten",
"ok": "OK",
"cancel": "Abbrechen",
"close": "Schließen",
"save": "Speichern",
"delete": "Löschen",
"edit": "Bearbeiten",
"submit": "Absenden",
"processing": "Wird verarbeitet...",
"noImage": "Kein Bild"
},
"nav": {
"drop": "Drop",
"pastDrops": "Vergangene Drops",
"community": "Community",
"orders": "Bestellungen",
"login": "Anmelden",
"logout": "Abmelden"
},
"header": {
"title": "Gemeinsam einkaufen. Wholesale-Preise für private Käufer.",
"subtitle": "Limitierte CBD Drops direkt von Schweizer Produzenten. Kein Retail. Kein Marketing-Aufschlag. Nur kollektive Mengenpreise."
},
"drop": {
"loading": "Lädt...",
"soldOut": "Drop ausverkauft",
"nextDropComing": "Nächster kollektiver Drop kommt bald",
"joinDrop": "Am Drop teilnehmen",
"reserved": "reserviert",
"of": "von",
"batch": "Batch",
"indoor": "Indoor",
"switzerland": "Schweiz",
"inclVat": "inkl. 2.5% MWST",
"perGram": "pro Gramm",
"selectQuantity": "Menge auswählen",
"customQuantity": "Individuelle Menge",
"minimumRequired": "Mindestens {minimum}g erforderlich (5 CHF Minimum)",
"maximumAvailable": "Maximal {maximum}g verfügbar",
"enterValidNumber": "Bitte geben Sie eine gültige Zahl ein",
"fillDeliveryInfo": "Bitte füllen Sie alle Lieferinformationen aus (Vollständiger Name, Adresse und Telefon)",
"fullName": "Vollständiger Name",
"address": "Adresse",
"phone": "Telefon",
"confirmPurchase": "Kauf bestätigen",
"totalPrice": "Gesamtpreis",
"standardPrice": "Standardpreis",
"wholesalePrice": "Großhandelspreis",
"paymentCurrency": "Zahlungswährung",
"selectCurrency": "Währung auswählen",
"upcomingIn": "Kommt in",
"day": "Tag",
"days": "Tage",
"hour": "Stunde",
"hours": "Stunden",
"minute": "Minute",
"minutes": "Minuten",
"paymentAddress": "Zahlungsadresse",
"paymentAmount": "Zahlungsbetrag",
"paymentId": "Zahlungs-ID",
"copyAddress": "Adresse kopieren",
"copied": "Kopiert!",
"paymentInstructions": "Senden Sie genau {amount} {currency} an die oben angegebene Adresse. Die Zahlung läuft in 20 Minuten ab.",
"paymentExpired": "Zahlung abgelaufen. Bitte versuchen Sie es erneut.",
"paymentPending": "Zahlung ausstehend...",
"paymentSuccess": "Zahlung erfolgreich!",
"paymentFailed": "Zahlung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"orderConfirmed": "Bestellung bestätigt!",
"orderFailed": "Bestellung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"dropSoldOut": "Drop ausverkauft",
"fullyReserved": "Der aktuelle kollektive Drop wurde vollständig reserviert.",
"nextDropComingSoon": "Nächster kollektiver Drop kommt bald.",
"batch": "Batch",
"reserved": "reserviert",
"wholesalePriceLabel": "Großhandelspreis:",
"standardPriceLabel": "Standardpreis:",
"standard": "Standard",
"wholesale": "Großhandel",
"unlock": "freischalten",
"unlockOnce": "Einmal freischalten. Großhandelspreis für immer behalten.",
"dropStartsIn": "Drop startet in",
"onHold": "in Wartestellung (10 Minuten Checkout-Fenster)",
"custom": "Individuell (g)",
"min": "Min",
"max": "Max",
"total": "Gesamt",
"standardTotal": "Standard gesamt",
"wholesaleTotal": "Großhandel gesamt",
"joinTheDrop": "Am Drop teilnehmen",
"noSubscription": "Kein Abonnement · Keine Verpflichtung",
"lessThanRemaining": "Weniger als {amount}{unit} verbleibend. Dieser Drop ist fast vollständig reserviert.",
"fullyReservedText": "Dieser Drop ist vollständig reserviert",
"item": "Artikel",
"quantity": "Menge",
"pricePerUnit": "Preis pro {unit}",
"deliveryInformation": "Lieferinformationen",
"fullNameRequired": "Vollständiger Name *",
"enterFullName": "Geben Sie Ihren vollständigen Namen ein",
"addressRequired": "Adresse *",
"enterAddress": "Geben Sie Ihre Lieferadresse ein",
"phoneRequired": "Telefonnummer *",
"enterPhone": "Geben Sie Ihre Telefonnummer ein",
"loadingCurrencies": "Lädt Währungen...",
"payWith": "Zahlen mit",
"completePayment": "Zahlung abschließen",
"amountToPay": "Zu zahlender Betrag",
"price": "Preis",
"subtotal": "Zwischensumme",
"shippingFee": "Versandgebühr",
"sendPaymentTo": "Senden Sie die Zahlung an diese Adresse",
"copyAddress": "Adresse kopieren",
"memoRequired": "Memo / Ziel-Tag (Erforderlich)",
"copyMemo": "Memo kopieren",
"paymentExpires": "Zahlung läuft ab",
"status": "Status",
"closingWarning": "⚠️ Das Schließen dieses Fensters wird Ihre Reservierung stornieren und den Bestand freigeben.",
"paymentConfirmed": "Zahlung bestätigt ✔️",
"orderProcessed": "Ihre Bestellung wurde erfolgreich verarbeitet und ist jetzt in diesem Drop reserviert.",
"whatHappensNext": "Was als Nächstes passiert",
"orderProcessed24h": "Ihre Bestellung wird innerhalb von 24 Stunden bearbeitet",
"shippedExpress": "Versand per Express-Lieferung",
"shippingConfirmation": "Sie erhalten eine Versandbestätigung und Tracking-Link per E-Mail",
"thankYouCollective": "Vielen Dank, dass Sie Teil des Kollektivs sind.",
"error": "⚠️ Fehler"
},
"infoBox": {
"whyCheap": "Warum so günstig?",
"whyCheapText": "Retailpreise liegen bei ca. 10 CHF/g. Durch kollektive Sammelbestellungen kaufen wir wie Grosshändler ein ohne Zwischenstufen.",
"taxesLegal": "Steuern & Recht",
"taxesLegalText": "Bulk-Verkauf mit 2.5% MWST. Keine Retail-Verpackung, keine Tabaksteuer.",
"dropModel": "Drop-Modell",
"dropModelText": "Pro Drop nur eine Sorte. Erst ausverkauft dann der nächste Drop."
},
"signup": {
"title": "Drop-Benachrichtigungen",
"subtitle": "Erhalte Updates zu neuen Drops per E-Mail oder WhatsApp.",
"email": "E-Mail",
"whatsapp": "WhatsApp Nummer",
"getNotified": "Benachrichtigen lassen",
"subscribing": "Wird abonniert...",
"successMessage": "Du erhältst eine Benachrichtigung, sobald ein neuer Drop verfügbar ist."
},
"pastDrops": {
"title": "Vergangene Drops",
"loading": "Lädt vergangene Drops...",
"noDrops": "Noch keine vergangenen Drops. Schauen Sie bald wieder vorbei!",
"soldOutIn": "Ausverkauft in",
"lessThan1h": "weniger als 1h",
"1h": "1h",
"hours": "{hours}h",
"1day": "1 Tag",
"days": "{days} Tage",
"daysHours": "{days}T {hours}h",
"more": "Mehr →"
},
"footer": {
"text": "© 2025 420Deals.ch · CBD < 1% THC · Verkauf ab 18 Jahren · Schweiz"
},
"auth": {
"login": "Anmelden",
"register": "Registrieren",
"username": "Benutzername",
"password": "Passwort",
"email": "E-Mail",
"referralId": "Empfehlungs-ID",
"optional": "optional",
"autoFilled": "✓ Automatisch von Empfehlungslink ausgefüllt",
"dontHaveAccount": "Haben Sie noch kein Konto?",
"alreadyHaveAccount": "Haben Sie bereits ein Konto?",
"anErrorOccurred": "Ein Fehler ist aufgetreten",
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten"
},
"unlockBar": {
"unlocked": "✅ Großhandelspreise freigeschaltet —",
"unlockedText": "Sie haben Zugang zu Großhandelspreisen!",
"locked": "🔒 Großhandelspreise gesperrt —",
"referralsCompleted": "{count} / {needed} Empfehlungen abgeschlossen",
"toGo": "{remaining} verbleibend",
"unlockText": "{needed} verifizierte Anmeldungen schalten Großhandelspreise für immer frei.",
"unlockNow": "Jetzt freischalten",
"innerCircleLocked": "🔒 Inner Circle Chat gesperrt —",
"innerCircleUnlockText": "{needed} verifizierte Anmeldungen schalten den Zugang zu unserem Inner Circle Chat für immer frei.",
"innerCircleUnlocked": "Inner Circle Chat freigeschaltet!"
},
"unlockModal": {
"title": "Großhandelspreise freischalten",
"referralsCompleted": "{count} von {needed} Empfehlungen abgeschlossen",
"inviteFriends": "Laden Sie {needed} Freunde zur Anmeldung ein.",
"unlockForever": "Sobald sie sich anmelden, werden die Großhandelspreise für immer freigeschaltet.",
"yourReferralLink": "Ihr Empfehlungslink",
"copyLink": "Link kopieren",
"copied": "Kopiert!",
"shareVia": "Teilen über",
"email": "E-Mail",
"whatsapp": "WhatsApp",
"referralStats": "Empfehlungsstatistiken",
"totalReferrals": "Gesamt Empfehlungen",
"verifiedReferrals": "Verifizierte Empfehlungen",
"pendingReferrals": "Ausstehende Empfehlungen",
"friendsMustSignUp": "Freunde müssen sich anmelden, damit es zählt.",
"referralsToGoSingular": "{remaining} Empfehlung verbleibend",
"referralsToGoPlural": "{remaining} Empfehlungen verbleibend"
},
"payment": {
"cancelled": "Zahlung wurde abgebrochen."
}
}

208
lib/translations/en.json Normal file
View File

@@ -0,0 +1,208 @@
{
"common": {
"loading": "Loading...",
"error": "An error occurred",
"ok": "OK",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"submit": "Submit",
"processing": "Processing...",
"noImage": "No Image"
},
"nav": {
"drop": "Drop",
"pastDrops": "Past Drops",
"community": "Community",
"orders": "Orders",
"login": "Login",
"logout": "Logout"
},
"header": {
"title": "Shop together. Wholesale prices for private buyers.",
"subtitle": "Limited CBD drops directly from Swiss producers. No retail. No markup. Just collective bulk prices."
},
"drop": {
"loading": "Loading...",
"soldOut": "Drop sold out",
"nextDropComing": "Next collective drop coming soon",
"joinDrop": "Join the Drop",
"reserved": "reserved",
"of": "of",
"batch": "Batch",
"indoor": "Indoor",
"switzerland": "Switzerland",
"inclVat": "incl. 2.5% VAT",
"perGram": "per gram",
"selectQuantity": "Select quantity",
"customQuantity": "Custom quantity",
"minimumRequired": "Minimum {minimum}g required (5 CHF minimum)",
"maximumAvailable": "Maximum {maximum}g available",
"enterValidNumber": "Please enter a valid number",
"fillDeliveryInfo": "Please fill in all delivery information (full name, address, and phone)",
"fullName": "Full Name",
"address": "Address",
"phone": "Phone",
"confirmPurchase": "Confirm Purchase",
"totalPrice": "Total Price",
"standardPrice": "Standard Price",
"wholesalePrice": "Wholesale Price",
"paymentCurrency": "Payment Currency",
"selectCurrency": "Select currency",
"upcomingIn": "Upcoming in",
"day": "day",
"days": "days",
"hour": "hour",
"hours": "hours",
"minute": "minute",
"minutes": "minutes",
"paymentAddress": "Payment Address",
"paymentAmount": "Payment Amount",
"paymentId": "Payment ID",
"copyAddress": "Copy Address",
"copied": "Copied!",
"paymentInstructions": "Send exactly {amount} {currency} to the address above. Payment expires in 20 minutes.",
"paymentExpired": "Payment expired. Please try again.",
"paymentPending": "Payment pending...",
"paymentSuccess": "Payment successful!",
"paymentFailed": "Payment failed. Please try again.",
"orderConfirmed": "Order confirmed!",
"orderFailed": "Order failed. Please try again.",
"dropSoldOut": "Drop Sold Out",
"fullyReserved": "The current collective drop has been fully reserved.",
"nextDropComingSoon": "Next collective drop coming soon.",
"wholesalePriceLabel": "Wholesale price:",
"standardPriceLabel": "Standard price:",
"standard": "Standard",
"wholesale": "Wholesale",
"unlock": "unlock",
"unlockOnce": "Unlock once. Keep wholesale forever.",
"dropStartsIn": "Drop starts in",
"onHold": "on hold (10 min checkout window)",
"custom": "Custom (g)",
"min": "Min",
"max": "Max",
"total": "Total",
"standardTotal": "Standard total",
"wholesaleTotal": "Wholesale total",
"joinTheDrop": "Join the drop",
"noSubscription": "No subscription · No obligation",
"lessThanRemaining": "Less than {amount}{unit} remaining. This drop is almost fully reserved.",
"fullyReservedText": "This drop is fully reserved",
"item": "Item",
"quantity": "Quantity",
"pricePerUnit": "Price per {unit}",
"deliveryInformation": "Delivery Information",
"fullNameRequired": "Full Name *",
"enterFullName": "Enter your full name",
"addressRequired": "Address *",
"enterAddress": "Enter your delivery address",
"phoneRequired": "Phone Number *",
"enterPhone": "Enter your phone number",
"loadingCurrencies": "Loading currencies...",
"payWith": "Pay with",
"completePayment": "Complete Payment",
"amountToPay": "Amount to Pay",
"price": "Price",
"subtotal": "Subtotal",
"shippingFee": "Shipping Fee",
"sendPaymentTo": "Send payment to this address",
"memoRequired": "Memo / Destination Tag (Required)",
"copyMemo": "Copy Memo",
"paymentExpires": "Payment expires",
"status": "Status",
"closingWarning": "⚠️ Closing this window will cancel your reservation and free up the inventory.",
"paymentConfirmed": "Payment confirmed ✔️",
"orderProcessed": "Your order has been successfully processed and is now reserved in this drop.",
"whatHappensNext": "What happens next",
"orderProcessed24h": "Your order will be processed within 24 hours",
"shippedExpress": "Shipped via express delivery",
"shippingConfirmation": "You'll receive a shipping confirmation and tracking link by email",
"thankYouCollective": "Thank you for being part of the collective.",
"error": "⚠️ Error"
},
"infoBox": {
"whyCheap": "Why so cheap?",
"whyCheapText": "Retail prices are around 10 CHF/g. Through collective bulk orders, we buy like wholesalers without intermediaries.",
"taxesLegal": "Taxes & Legal",
"taxesLegalText": "Bulk sale with 2.5% VAT. No retail packaging, no tobacco tax.",
"dropModel": "Drop Model",
"dropModelText": "One variety per drop. Only when sold out then the next drop."
},
"signup": {
"title": "Drop Notifications",
"subtitle": "Receive updates about new drops via email or WhatsApp.",
"email": "E-Mail",
"whatsapp": "WhatsApp Number",
"getNotified": "Get Notified",
"subscribing": "Subscribing...",
"successMessage": "You will receive a notification as soon as a new drop drops."
},
"pastDrops": {
"title": "Past Drops",
"loading": "Loading past drops...",
"noDrops": "No past drops yet. Check back soon!",
"soldOutIn": "Sold out in",
"lessThan1h": "less than 1h",
"1h": "1h",
"hours": "{hours}h",
"1day": "1 day",
"days": "{days} days",
"daysHours": "{days}d {hours}h",
"more": "More →"
},
"footer": {
"text": "© 2025 420Deals.ch · CBD < 1% THC · Sale from 18 years · Switzerland"
},
"auth": {
"login": "Login",
"register": "Register",
"username": "Username",
"password": "Password",
"email": "Email",
"referralId": "Referral ID",
"optional": "optional",
"autoFilled": "✓ Auto-filled from referral link",
"dontHaveAccount": "Don't have an account?",
"alreadyHaveAccount": "Already have an account?",
"anErrorOccurred": "An error occurred",
"unexpectedError": "An unexpected error occurred"
},
"unlockBar": {
"unlocked": "✅ Wholesale prices unlocked —",
"unlockedText": "You have access to wholesale pricing!",
"locked": "🔒 Wholesale prices locked —",
"referralsCompleted": "{count} / {needed} referrals completed",
"toGo": "{remaining} to go",
"unlockText": "{needed} verified sign-ups unlock wholesale prices forever.",
"unlockNow": "Unlock now",
"innerCircleLocked": "🔒 Inner circle chat locked —",
"innerCircleUnlockText": "{needed} verified sign-ups unlock access to our Inner circle chat forever.",
"innerCircleUnlocked": "Inner circle chat unlocked!"
},
"unlockModal": {
"title": "Unlock Wholesale Prices",
"referralsCompleted": "{count} of {needed} referrals completed",
"inviteFriends": "Invite {needed} friends to sign up.",
"unlockForever": "Once they do, wholesale prices unlock forever.",
"yourReferralLink": "Your referral link",
"copyLink": "Copy Link",
"copied": "Copied!",
"shareVia": "Share via",
"email": "Email",
"whatsapp": "WhatsApp",
"referralStats": "Referral Stats",
"totalReferrals": "Total Referrals",
"verifiedReferrals": "Verified Referrals",
"pendingReferrals": "Pending Referrals",
"friendsMustSignUp": "Friends must sign up to count.",
"referralsToGoSingular": "{remaining} referral to go",
"referralsToGoPlural": "{remaining} referrals to go"
},
"payment": {
"cancelled": "Payment was cancelled."
}
}