Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36806cac5b | ||
|
|
b1162761de | ||
|
|
8b362c9436 | ||
|
|
cd45d8f578 | ||
|
|
a51ce78538 | ||
|
|
ab7e560a3d | ||
|
|
081893ef57 | ||
|
|
e2d7bbb043 | ||
|
|
3347febb3c | ||
|
|
d43b615297 | ||
|
|
7f6c1e6307 | ||
|
|
5bbf0aabcd | ||
|
|
fa172860db | ||
|
|
a51e9456d6 | ||
|
|
540da1af24 | ||
|
|
e28faea546 | ||
|
|
8055c003ac | ||
|
|
94803bfb67 | ||
|
|
cf3681260b | ||
|
|
580adac33a | ||
|
|
a4240b6474 | ||
|
|
326e1fac54 | ||
|
|
e3a6267059 | ||
|
|
24851184c4 | ||
|
|
a107184010 | ||
|
|
af0245de43 | ||
|
|
afa1993c53 | ||
|
|
78c8165c04 | ||
|
|
fe55cb5f33 | ||
|
|
038ea05d56 | ||
|
|
ba6d68c5b9 | ||
|
|
1598f053ee | ||
|
|
279339a095 | ||
|
|
df9374a070 | ||
|
|
97c12efdb1 | ||
|
|
2cac70c53a | ||
|
|
46b3f29674 | ||
|
|
7a8b9cf4c0 | ||
|
|
0fb606ff3f | ||
|
|
2d85591a43 | ||
|
|
7577426f49 | ||
|
|
c5f7b28aa0 | ||
|
|
6c7d11ca78 |
@@ -59,6 +59,10 @@ MAIL_USERNAME=null
|
|||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
CONTACT_EMAIL=
|
||||||
|
|
||||||
|
HCAPTCHA_SITEKEY=
|
||||||
|
HCAPTCHA_SECRET=
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
|||||||
@@ -10,11 +10,19 @@ RUN composer install \
|
|||||||
--optimize-autoloader \
|
--optimize-autoloader \
|
||||||
--ignore-platform-reqs
|
--ignore-platform-reqs
|
||||||
|
|
||||||
# ── Stage 2a: Vite dev (node_modules only, no build) ─────────────────────────
|
# ── Stage 2a: Vite dev (node_modules only, no build) ──────────────────────────
|
||||||
FROM node:22-alpine AS vite-dev
|
FROM node:22-alpine AS vite-dev
|
||||||
|
# git: needed for npm to install @boboko/core's git+https tagged-VCS form (see
|
||||||
|
# package.json's _dependencies and docker/entrypoint-vite.sh). No build-time
|
||||||
|
# `npm install` here — package.json's boboko/core path-repo form (../boboko-core)
|
||||||
|
# isn't visible in the build context, only once bind-mounted at container start,
|
||||||
|
# and entrypoint-vite.sh already runs npm install on every boot, so this would be
|
||||||
|
# redundant even if it could work.
|
||||||
|
RUN apk add --no-cache git
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY docker/entrypoint-vite.sh /entrypoint-vite.sh
|
||||||
RUN npm ci --ignore-scripts
|
RUN chmod +x /entrypoint-vite.sh
|
||||||
|
ENTRYPOINT ["/entrypoint-vite.sh"]
|
||||||
|
|
||||||
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS node-build
|
FROM node:22-alpine AS node-build
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 3dealer
|
||||||
|
|
||||||
|
Storefront for 3dealer.gr: Laravel 12 + Lunar PHP (headless), on top of `boboko/core`. Front-end conventions are in [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
|
## Scheduled jobs
|
||||||
|
|
||||||
|
These run in the `scheduler` container (`php artisan schedule:work`).
|
||||||
|
|
||||||
|
| Command | When | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `custom-fields:prune-uploads` | Daily, 04:00 | Deletes custom-field photo uploads older than 24 h that no cart line or order line references. |
|
||||||
|
| `lunar:search:index` | Daily, 03:00 | Full product reindex. Registered by `boboko/core`. |
|
||||||
|
|
||||||
|
## Product custom fields
|
||||||
|
|
||||||
|
Admins can add custom fields to a product in the Lunar admin (the product's **Custom Fields** section, from `boboko/core`). The customer fills them in on the product page before adding the product to the cart. The answers are stored on the cart line (`meta.custom_fields`) and carried over to the order line.
|
||||||
|
|
||||||
|
| Field type | Storefront input | Limit |
|
||||||
|
|---|---|---|
|
||||||
|
| Short text | text input | 255 characters |
|
||||||
|
| Long text | textarea | 2,000 characters |
|
||||||
|
| File upload | photo upload | JPG, PNG, WEBP, HEIC/HEIF, up to 10 MB |
|
||||||
|
|
||||||
|
- **Photos upload as soon as they're picked.** They go to `POST /{locale}/custom-field-uploads`, which is limited to 20 per minute per client. They're stored on the private `local` disk under `storage/app/private/custom-field-uploads/`. Only an encrypted reference is sent with add-to-cart. The allowed types and size are set in `App\Http\Controllers\CustomFieldUploadController`.
|
||||||
|
- **Photos are never public.** The cart drawer, checkout summary and order confirmation link to a photo through a signed URL that expires after 2 hours.
|
||||||
|
- **Emails show text answers only**, never photos.
|
||||||
|
- **Photos are cleaned up automatically.** Photos never added to a cart are deleted by `custom-fields:prune-uploads` (see above). A photo on a cart line is kept as long as that cart line exists, and a photo on an order is kept indefinitely.
|
||||||
|
- **Products with custom fields can't be quick-added.** On product cards, the "add to cart" button becomes a link to the product page.
|
||||||
|
- Field labels are entered once in the admin and aren't translated, so they appear as entered in both `/el` and `/en`.
|
||||||
@@ -19,7 +19,7 @@ final class ProductCard
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $product one item from ProductService's localized array shape
|
* @param array<string, mixed> $product one item from ProductService's localized array shape
|
||||||
* @return array{name: ?string, price: ?string, image: ?string, href: string}
|
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool, soldOut: bool}
|
||||||
*/
|
*/
|
||||||
public static function fromIndexed(array $product): array
|
public static function fromIndexed(array $product): array
|
||||||
{
|
{
|
||||||
@@ -28,6 +28,20 @@ public static function fromIndexed(array $product): array
|
|||||||
'price' => $product['price'],
|
'price' => $product['price'],
|
||||||
'image' => $product['media'][0]['url'] ?? null,
|
'image' => $product['media'][0]['url'] ?? null,
|
||||||
'href' => route('product.show', ['id' => $product['id']]),
|
'href' => route('product.show', ['id' => $product['id']]),
|
||||||
|
// The card's quick "Add to cart" always adds this variant, same
|
||||||
|
// default Modules\Core\Catalog\Services\ProductService::
|
||||||
|
// variantSummaries() and product/show.blade.php both use — no
|
||||||
|
// picker at listing-grid scope, unlike the product page's own
|
||||||
|
// color swatches.
|
||||||
|
'variantId' => $product['variants'][0]['id'] ?? null,
|
||||||
|
// A product with custom fields (photo upload, engraving text…)
|
||||||
|
// can't be quick-added from a card — the card links to the
|
||||||
|
// product page instead, even when every field is optional.
|
||||||
|
'hasCustomFields' => ! empty($product['custom_fields']),
|
||||||
|
// Index-time stock (see boboko-core's ProductIndexer `in_stock`),
|
||||||
|
// so only as fresh as the last reindex. A document missing the
|
||||||
|
// field is treated as in stock rather than hiding its cart button.
|
||||||
|
'soldOut' => ! ($product['in_stock'] ?? true),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,14 @@
|
|||||||
*/
|
*/
|
||||||
final class ProductListingPage
|
final class ProductListingPage
|
||||||
{
|
{
|
||||||
private const PER_PAGE = 12;
|
private const PER_PAGE = 40;
|
||||||
|
|
||||||
|
/** Category pages rarely have enough products to fill a page, so this
|
||||||
|
* ceiling is high enough to act as "no pagination" in practice — the
|
||||||
|
* pagination component self-hides via hasPages() when everything fits.
|
||||||
|
* If a category ever exceeds it, pagination reappears as a safety net
|
||||||
|
* rather than silently truncating results. */
|
||||||
|
private const CATEGORY_PER_PAGE = 200;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProductService $products,
|
private readonly ProductService $products,
|
||||||
@@ -33,6 +40,7 @@ public function __construct(
|
|||||||
public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array
|
public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array
|
||||||
{
|
{
|
||||||
$filters = $listing->filters($collectionId);
|
$filters = $listing->filters($collectionId);
|
||||||
|
$perPage = $collectionId !== null ? self::CATEGORY_PER_PAGE : self::PER_PAGE;
|
||||||
|
|
||||||
// Listing reads from the Meilisearch index via ProductService/
|
// Listing reads from the Meilisearch index via ProductService/
|
||||||
// ProductSearchService, not Eloquent. Both return a
|
// ProductSearchService, not Eloquent. Both return a
|
||||||
@@ -45,12 +53,12 @@ public function build(ProductListing $listing, Closure $url, ?int $collectionId
|
|||||||
query: $query,
|
query: $query,
|
||||||
filters: $filters,
|
filters: $filters,
|
||||||
sort: $listing->sort,
|
sort: $listing->sort,
|
||||||
perPage: self::PER_PAGE,
|
perPage: $perPage,
|
||||||
page: $listing->page,
|
page: $listing->page,
|
||||||
)
|
)
|
||||||
: $this->products->list(
|
: $this->products->list(
|
||||||
filters: $filters,
|
filters: $filters,
|
||||||
perPage: self::PER_PAGE,
|
perPage: $perPage,
|
||||||
page: $listing->page,
|
page: $listing->page,
|
||||||
sort: $listing->sort,
|
sort: $listing->sort,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Lunar\Models\Country;
|
||||||
|
use Lunar\Models\State;
|
||||||
|
use Modules\Core\Cart\Services\CartService;
|
||||||
|
use Modules\Core\Checkout\Services\CheckoutService;
|
||||||
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
|
use Modules\Core\Privacy\Services\PrivacyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile page: name, invoice details, the one address, and account
|
||||||
|
* deletion. The customer keeps a single address, flagged as both the
|
||||||
|
* shipping and billing default, so allowing several later is a UI change
|
||||||
|
* only (the data is already Lunar's own addresses table).
|
||||||
|
*/
|
||||||
|
class AccountController extends Controller
|
||||||
|
{
|
||||||
|
// Single-country store, same as CheckoutController::STORE_COUNTRY_ISO3.
|
||||||
|
private const STORE_COUNTRY_ISO3 = 'GRC';
|
||||||
|
|
||||||
|
private const ADDRESS_FIELDS = ['line_one', 'city', 'postcode', 'state', 'contact_phone'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CustomerAccountService $account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$customer = $this->account->customer($user);
|
||||||
|
|
||||||
|
return view('account.show', [
|
||||||
|
'user' => $user,
|
||||||
|
'customer' => $customer,
|
||||||
|
'address' => $this->defaultAddress($user),
|
||||||
|
'regions' => State::where('country_id', $this->storeCountry()->id)->orderBy('name')->get(['id', 'name']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(string $locale, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$country = $this->storeCountry();
|
||||||
|
|
||||||
|
// The address is all-or-nothing: typing any part of it makes the rest
|
||||||
|
// (and the name, which Lunar requires on every address) required.
|
||||||
|
$anyAddressField = implode(',', self::ADDRESS_FIELDS);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'first_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'last_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'invoice' => ['boolean'],
|
||||||
|
'company_name' => ['nullable', 'string', 'max:255', 'required_if_accepted:invoice'],
|
||||||
|
'tax_identifier' => ['nullable', 'digits:9', 'required_if_accepted:invoice'],
|
||||||
|
'line_one' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'city' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'postcode' => ['nullable', 'regex:/^\d{3}\s?\d{2}$/', 'required_with:'.$anyAddressField],
|
||||||
|
'state' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'required_with:'.$anyAddressField,
|
||||||
|
Rule::exists((new State)->getTable(), 'name')->where('country_id', $country->id),
|
||||||
|
],
|
||||||
|
'contact_phone' => ['nullable', 'string', 'max:30'],
|
||||||
|
'recovery_consent' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invoice = $request->boolean('invoice');
|
||||||
|
|
||||||
|
$this->account->updateProfile($user, [
|
||||||
|
'first_name' => $data['first_name'] ?? null,
|
||||||
|
'last_name' => $data['last_name'] ?? null,
|
||||||
|
'company_name' => $invoice ? $data['company_name'] : null,
|
||||||
|
'tax_identifier' => $invoice ? $data['tax_identifier'] : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (filled($data['line_one'] ?? null)) {
|
||||||
|
$addressData = [
|
||||||
|
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
|
||||||
|
'first_name' => $data['first_name'],
|
||||||
|
'last_name' => $data['last_name'],
|
||||||
|
'country_id' => $country->id,
|
||||||
|
'contact_email' => $user->email,
|
||||||
|
'shipping_default' => true,
|
||||||
|
'billing_default' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
$existing = $this->defaultAddress($user);
|
||||||
|
|
||||||
|
$existing
|
||||||
|
? $this->account->updateAddress($user, $existing->id, $addressData)
|
||||||
|
: $this->account->createAddress($user, $addressData);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateRecoveryConsent($user, $request->boolean('recovery_consent'));
|
||||||
|
|
||||||
|
return redirect()->route('account')->with('status', __('storefront.account.saved'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Email me a reminder if I don't finish my order", as a standing
|
||||||
|
* choice — stored on the customer via boboko-core's
|
||||||
|
* CustomerAccountService::setRecoveryConsent(), and applied to the
|
||||||
|
* current cart too, so opting out stops reminders for it right away.
|
||||||
|
*/
|
||||||
|
private function updateRecoveryConsent($user, bool $consent): void
|
||||||
|
{
|
||||||
|
$this->account->setRecoveryConsent($user, $consent);
|
||||||
|
|
||||||
|
// Only an existing cart; never create one just to record this.
|
||||||
|
$cart = app(CartService::class)->current();
|
||||||
|
|
||||||
|
if ($cart && (bool) data_get($cart, 'meta.recovery_consent') !== $consent) {
|
||||||
|
app(CheckoutService::class)->setRecoveryConsent($consent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service deletion: opens core's 30-day grace-period erasure request
|
||||||
|
* (which blocks the login right away) and logs out. Logging back in within
|
||||||
|
* the grace period cancels it; see core's docs/privacy.md.
|
||||||
|
*/
|
||||||
|
public function destroy(string $locale, Request $request, PrivacyService $privacy): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$privacy->requestErasureForUser($user, $user);
|
||||||
|
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login')->with('status', __('storefront.account.deletion_requested'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultAddress($user)
|
||||||
|
{
|
||||||
|
$addresses = collect($this->account->addresses($user));
|
||||||
|
|
||||||
|
return $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeCountry(): Country
|
||||||
|
{
|
||||||
|
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->firstOrFail();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||||
|
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
|
||||||
|
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
|
||||||
|
use Modules\Core\Customer\Services\CustomerEmailChangeService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changing the login email — a thin wrapper over boboko-core's
|
||||||
|
* Customer\Services\CustomerEmailChangeService, which owns the actual
|
||||||
|
* request/confirm mechanics, throttling, pending-change storage, and
|
||||||
|
* mailables. This controller's own job is just the storefront's session-
|
||||||
|
* scoped "which email did I just ask to switch to" UI state (so the
|
||||||
|
* .code/.resend pages know which address to show/resend to) and
|
||||||
|
* translating the service's exceptions into the flash-message flow the
|
||||||
|
* views expect.
|
||||||
|
*/
|
||||||
|
class EmailController extends Controller
|
||||||
|
{
|
||||||
|
private const SESSION_KEY = 'pending_email_change';
|
||||||
|
|
||||||
|
public function edit(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
return view('account.email', ['user' => $request->user()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$request->merge(['email' => Str::lower(trim((string) $request->input('email')))]);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::notIn([$user->email]),
|
||||||
|
],
|
||||||
|
], [
|
||||||
|
'email.not_in' => __('storefront.account.email_same'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$emailChange->request($user, $validated['email']);
|
||||||
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
return back()->withInput()->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put(self::SESSION_KEY, $validated['email']);
|
||||||
|
|
||||||
|
return redirect()->route('account.email.code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function code(string $locale, Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
if (! $pendingEmail) {
|
||||||
|
return redirect()->route('account.email.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('account.email-code', ['email' => $pendingEmail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resend(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
|
{
|
||||||
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
if (! $pendingEmail) {
|
||||||
|
return redirect()->route('account.email.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$emailChange->request($request->user(), $pendingEmail);
|
||||||
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return redirect()->route('account.email.edit')
|
||||||
|
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', __('storefront.auth.code_resent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
|
{
|
||||||
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
if (! $pendingEmail) {
|
||||||
|
return redirect()->route('account.email.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate(['code' => ['required', 'digits:6']]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$emailChange->confirm($request->user(), $validated['code']);
|
||||||
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return redirect()->route('account.email.edit')
|
||||||
|
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||||
|
} catch (InvalidEmailChangeCodeException) {
|
||||||
|
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
|
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
|
||||||
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order history. Every lookup goes through CustomerAccountService, which only
|
||||||
|
* ever returns the logged-in user's own placed orders, so an order id from
|
||||||
|
* the URL can't reach someone else's order.
|
||||||
|
*/
|
||||||
|
class OrderController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CustomerAccountService $account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
return view('account.orders.index', [
|
||||||
|
'orders' => $this->account->orders($request->user(), 15),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $locale, Request $request, int $orderId): View
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$order = $this->account->order($request->user(), $orderId);
|
||||||
|
} catch (OrderNotFoundException) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// By type, like the checkout confirmation: the method may since have
|
||||||
|
// been disabled, but the order still shows what was used.
|
||||||
|
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||||
|
->first()
|
||||||
|
?->translate('name');
|
||||||
|
|
||||||
|
return view('account.orders.show', [
|
||||||
|
'order' => $order,
|
||||||
|
'paymentMethodName' => $paymentMethodName,
|
||||||
|
'shipments' => $order->shipments->whereNull('cancelled_at')->whereNotNull('tracking_reference'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Rules\HCaptcha;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||||
|
use Modules\Core\Auth\Services\UserOtpService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwordless customer login: email → emailed 6-digit code → logged in.
|
||||||
|
* Login and registration are the same flow — UserOtpService::generateAndSend()
|
||||||
|
* find-or-creates the user (and its Customer).
|
||||||
|
*
|
||||||
|
* All the security lives in UserOtpService: per-email code-request throttling
|
||||||
|
* (OtpThrottledException), wrong-guess lockout, the Auth::login() itself (which
|
||||||
|
* also regenerates the session and merges the guest cart via Lunar's Login
|
||||||
|
* listener) and the session-registry record. This controller only moves the
|
||||||
|
* shopper between the two steps, carrying the email in the session rather
|
||||||
|
* than the URL.
|
||||||
|
*/
|
||||||
|
class LoginController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* `?redirect=/el/checkout` (e.g. from the checkout's login tab) becomes the
|
||||||
|
* intended URL that verify() returns to. Only a same-site path is accepted:
|
||||||
|
* no scheme, no protocol-relative `//host`, so it can't redirect off-site.
|
||||||
|
*/
|
||||||
|
public function create(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
$redirect = (string) $request->query('redirect', '');
|
||||||
|
|
||||||
|
if (preg_match('#^/(?![/\\\\])#', $redirect)) {
|
||||||
|
$request->session()->put('url.intended', url($redirect));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||||
|
{
|
||||||
|
// Captcha only here: verify() and resend() need the email this step
|
||||||
|
// puts in the session, so they can't be reached without passing it.
|
||||||
|
$validated = $request->validate([
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||||
|
], [
|
||||||
|
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$email = Str::lower(trim($validated['email']));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$otp->generateAndSend($email);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withInput()->withErrors([
|
||||||
|
'email' => __('storefront.auth.too_many_codes'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('login.email', $email);
|
||||||
|
|
||||||
|
return redirect()->route('login.code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function code(string $locale, Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$email = $request->session()->get('login.email');
|
||||||
|
|
||||||
|
if (! $email) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('auth.login-code', ['email' => $email]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resend(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = $request->session()->get('login.email');
|
||||||
|
|
||||||
|
if (! $email) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$otp->generateAndSend($email);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withErrors([
|
||||||
|
'code' => __('storefront.auth.too_many_codes'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', __('storefront.auth.code_resent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = $request->session()->get('login.email');
|
||||||
|
|
||||||
|
if (! $email) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'code' => ['required', 'digits:6'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wrong, expired, or locked out after too many guesses — core doesn't
|
||||||
|
// say which, so neither do we; the page offers "resend code" for all three.
|
||||||
|
if (! $otp->validate($email, $validated['code'], $request)) {
|
||||||
|
return back()->withErrors([
|
||||||
|
'code' => __('storefront.auth.invalid_code'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->forget('login.email');
|
||||||
|
|
||||||
|
return redirect()->intended(route('home'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(string $locale, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('home');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,71 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\ContactRequest;
|
||||||
|
use App\Mail\ContactConfirmationMail;
|
||||||
|
use App\Mail\ContactMessageMail;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact form. Nothing is stored: the message is emailed to the store
|
||||||
|
* (CONTACT_EMAIL) and the sender gets a generic confirmation. Both are sent
|
||||||
|
* synchronously so a failed store email can be reported back on the form.
|
||||||
|
*
|
||||||
|
* Limited per IP in here rather than with throttle middleware, so the limit
|
||||||
|
* shows as a message on the form instead of a bare 429 page.
|
||||||
|
*/
|
||||||
class ContactController extends Controller
|
class ContactController extends Controller
|
||||||
{
|
{
|
||||||
public function index(string $locale)
|
private const SEND_LIMIT = 3;
|
||||||
|
private const SEND_DECAY_SECONDS = 600;
|
||||||
|
|
||||||
|
public function index(string $locale): View
|
||||||
{
|
{
|
||||||
return view('contact');
|
return view('contact');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function send(string $locale, ContactRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$key = 'contact:'.$request->ip();
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($key, self::SEND_LIMIT)) {
|
||||||
|
return back()->withInput()->with('contact_error', __('storefront.contact.too_many'));
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit($key, self::SEND_DECAY_SECONDS);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
$to = config('services.contact.email');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (blank($to)) {
|
||||||
|
throw new \RuntimeException('CONTACT_EMAIL is not set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Mail::to($to)->send(new ContactMessageMail(
|
||||||
|
$data['name'],
|
||||||
|
$data['email'],
|
||||||
|
$data['message'],
|
||||||
|
$locale,
|
||||||
|
));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('Contact form: store email failed', ['exception' => $e]);
|
||||||
|
|
||||||
|
return back()->withInput()->with('contact_error', __('storefront.contact.send_failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The store already has the message, so a failed confirmation is only logged.
|
||||||
|
try {
|
||||||
|
Mail::to($data['email'])->locale($locale)->send(new ContactConfirmationMail);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Contact form: confirmation email failed', ['exception' => $e]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('contact')->with('status', __('storefront.contact.sent'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\Core\File\Http\Controllers\UploadFileController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the shopper's photo for a product custom field of type `file` (see
|
||||||
|
* boboko-core's Product::$custom_fields) the moment it's picked on the product
|
||||||
|
* page — before add-to-cart, see custom-field-upload-controller.js.
|
||||||
|
*
|
||||||
|
* Which files are acceptable (extensions, size) is a per-site decision — this
|
||||||
|
* site: photographs — so it lives here as this app's own policy, extending
|
||||||
|
* boboko-core's Modules\Core\File\Http\Controllers\UploadFileController for
|
||||||
|
* the actual store()/validate()/respond() mechanics. The module's add-to-cart
|
||||||
|
* endpoint only ever receives the stored File row's own `id` — FileService is
|
||||||
|
* the single source of truth for disk/path/name/mime, never duplicated into
|
||||||
|
* cart/order line meta (see Checkout\CartController::customFieldsMeta()). A
|
||||||
|
* shopper can't point a cart line at someone else's file: CartController only
|
||||||
|
* accepts an id that is both unowned and tagged with this exact PURPOSE.
|
||||||
|
*
|
||||||
|
* Stored on the private `local` disk: these are customers' personal photos,
|
||||||
|
* never reachable by a public URL except through FileService's own signed
|
||||||
|
* download route. Uploads nobody adds to a cart are removed by core's
|
||||||
|
* `boboko:file:prune-unowned custom-field-upload` command.
|
||||||
|
*/
|
||||||
|
class CustomFieldUploadController extends UploadFileController
|
||||||
|
{
|
||||||
|
public const PURPOSE = 'custom-field-upload';
|
||||||
|
|
||||||
|
public const MAX_KILOBYTES = 10240;
|
||||||
|
|
||||||
|
public const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the file input's `accept` attribute — same list the server enforces.
|
||||||
|
*/
|
||||||
|
public static function accept(): string
|
||||||
|
{
|
||||||
|
return '.'.implode(',.', self::EXTENSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function purpose(): string
|
||||||
|
{
|
||||||
|
return self::PURPOSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validationRules(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// `label` is the admin-authored field label, only used as the
|
||||||
|
// :attribute in the validation message shown next to that field.
|
||||||
|
protected function validationAttributes(Request $request): array
|
||||||
|
{
|
||||||
|
return ['file' => (string) $request->input('label', 'file')];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,15 @@
|
|||||||
|
|
||||||
use App\Catalog\ProductListing;
|
use App\Catalog\ProductListing;
|
||||||
use App\Catalog\ProductListingPage;
|
use App\Catalog\ProductListingPage;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Lunar\Models\Product;
|
||||||
|
use Lunar\Models\ProductVariant;
|
||||||
use Modules\Core\Catalog\Services\ProductService;
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
use Modules\Core\Review\Models\ProductReview;
|
||||||
|
|
||||||
class ProductController extends Controller
|
class ProductController extends Controller
|
||||||
{
|
{
|
||||||
@@ -35,18 +42,208 @@ public function show(string $locale, int $id)
|
|||||||
$product = $this->products->getById($id);
|
$product = $this->products->getById($id);
|
||||||
abort_if($product === null, Response::HTTP_NOT_FOUND);
|
abort_if($product === null, Response::HTTP_NOT_FOUND);
|
||||||
|
|
||||||
|
$product = $this->mergeJustSubmittedReview($product);
|
||||||
|
|
||||||
$collection = $product['collections'][0] ?? null;
|
$collection = $product['collections'][0] ?? null;
|
||||||
|
// dd($product);
|
||||||
$variantsData = $this->products->variantSummaries($product);
|
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
|
||||||
|
|
||||||
$firstVariant = $product['variants'][0] ?? null;
|
|
||||||
$option = $firstVariant['options'][0]['option'] ?? null;
|
|
||||||
|
|
||||||
return view('product.show', [
|
return view('product.show', [
|
||||||
'collection' => $collection,
|
'collection' => $collection,
|
||||||
'product' => $product,
|
'product' => $product,
|
||||||
'option' => $option,
|
'productOptions' => $productOptions,
|
||||||
'variantsData' => $variantsData,
|
'variantsData' => $variantsData,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One button/swatch group per product option (a product can have several
|
||||||
|
* — e.g. a custom-photo product with size + style + person-count, each
|
||||||
|
* combination resolved client-side to one exact ProductVariant, see
|
||||||
|
* product-form-controller.js) plus the per-variant data the picker
|
||||||
|
* resolves a selection against.
|
||||||
|
*
|
||||||
|
* Reads live Eloquent rather than the Meilisearch-indexed $product array
|
||||||
|
* the rest of the page uses (same reasoning as checkStock() below): the
|
||||||
|
* index has no concept of option/value display order (Lunar's
|
||||||
|
* `position` column) or a stable value id, both of which the picker
|
||||||
|
* needs — to render values in the merchant's intended order, and to
|
||||||
|
* match a combination back to one exact variant without relying on
|
||||||
|
* translated label strings staying unique.
|
||||||
|
*
|
||||||
|
* @return array{0: array, 1: array}
|
||||||
|
*/
|
||||||
|
private function buildOptionPicker(int $productId): array
|
||||||
|
{
|
||||||
|
$variants = ProductVariant::query()
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->with(['values.option', 'prices', 'images'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$productOptions = $variants
|
||||||
|
->flatMap(fn (ProductVariant $variant) => $variant->values)
|
||||||
|
->unique('id')
|
||||||
|
->groupBy(fn ($value) => $value->option->handle)
|
||||||
|
->map(function ($values, $handle) {
|
||||||
|
$sorted = $values->sortBy([['position', 'asc'], ['id', 'asc']]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'handle' => $handle,
|
||||||
|
'label' => $sorted->first()->option->translate('name'),
|
||||||
|
// The "Color" option type is the only one that writes a
|
||||||
|
// hex code into meta (see boboko/core's ColorOptionType)
|
||||||
|
// — its presence is how we tell a color option (swatches)
|
||||||
|
// from any other option (buttons).
|
||||||
|
'isColor' => $sorted->contains(fn ($v) => !empty($v->meta['hex'] ?? null)),
|
||||||
|
'values' => $sorted->map(fn ($v) => [
|
||||||
|
'id' => $v->id,
|
||||||
|
'label' => $v->translate('name'),
|
||||||
|
'hex' => $v->meta['hex'] ?? null,
|
||||||
|
])->values()->all(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$variantsData = $variants->map(fn (ProductVariant $variant) => [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'price' => $variant->prices->first()?->price?->decimal(),
|
||||||
|
'image' => $variant->images->first()?->getUrl(),
|
||||||
|
// Live from the DB (not the index's in_stock), with Lunar's own
|
||||||
|
// purchasability rule — drives the disabled add-to-cart button.
|
||||||
|
'inStock' => $variant->canBeFulfilledAtQuantity(1),
|
||||||
|
// handle => selected value id, for matching a combination of
|
||||||
|
// selections back to this variant — see selectVariant() in
|
||||||
|
// product-form-controller.js.
|
||||||
|
'options' => $variant->values->mapWithKeys(fn ($v) => [$v->option->handle => $v->id])->all(),
|
||||||
|
])->values()->all();
|
||||||
|
|
||||||
|
return [$productOptions, $variantsData];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meilisearch's own write API is itself async — addDocuments() enqueues an
|
||||||
|
* indexing task and returns immediately, and Laravel\Scout\Engines\
|
||||||
|
* MeilisearchEngine::update() never waits on that task, so even
|
||||||
|
* $product->searchableSync() (which only skips OUR queue) can still land
|
||||||
|
* the shopper back on this page before Meilisearch has actually processed
|
||||||
|
* the write. storeReview() flashes the review it just created for exactly
|
||||||
|
* this one next request; splice it in here rather than trust the index is
|
||||||
|
* already caught up. Guarded by id so a race the other way — the index
|
||||||
|
* DID catch up in time — doesn't show the same review twice.
|
||||||
|
*/
|
||||||
|
private function mergeJustSubmittedReview(array $product): array
|
||||||
|
{
|
||||||
|
$justSubmitted = session('justSubmittedReview');
|
||||||
|
|
||||||
|
if (! $justSubmitted || (string) ($justSubmitted['product_id'] ?? null) !== (string) $product['id']) {
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $product['reviews']['items'] ?? [];
|
||||||
|
|
||||||
|
if (collect($items)->contains('id', $justSubmitted['id'])) {
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [$justSubmitted, ...$items];
|
||||||
|
|
||||||
|
$product['reviews']['items'] = $items;
|
||||||
|
$product['reviews']['count'] = count($items);
|
||||||
|
$product['reviews']['average_rating'] = round(collect($items)->avg('rating'), 1);
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A storefront-owned, checkout-module-independent stock check — the
|
||||||
|
* product page's "Add to cart" calls this first and only submits to the
|
||||||
|
* checkout module's own add-to-cart endpoint once this says `ok`. Reads
|
||||||
|
* the live Eloquent ProductVariant directly (not the Meilisearch index
|
||||||
|
* ProductService otherwise reads from, which can lag behind an actual
|
||||||
|
* sale until the next reindex) via the SAME method Lunar's own
|
||||||
|
* CartLineStock validator calls, so this can never disagree with what
|
||||||
|
* the module's own server-side check would decide.
|
||||||
|
*/
|
||||||
|
public function checkStock(string $locale, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'variant' => ['required', 'integer'],
|
||||||
|
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$variant = ProductVariant::find($data['variant']);
|
||||||
|
$quantity = $data['quantity'] ?? 1;
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => $variant->canBeFulfilledAtQuantity($quantity),
|
||||||
|
'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* boboko/core's product_reviews table has no moderation/status column, so
|
||||||
|
* this goes live immediately — no approval queue to land in.
|
||||||
|
*/
|
||||||
|
public function storeReview(string $locale, Request $request, Product $product): RedirectResponse
|
||||||
|
{
|
||||||
|
$reviewsUrl = route('product.show', [
|
||||||
|
'locale' => $locale,
|
||||||
|
'id' => $product->id,
|
||||||
|
'tab' => 'reviews',
|
||||||
|
]).'#product-tabs';
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'rating' => ['required', 'integer', 'between:1,5'],
|
||||||
|
'content' => ['required', 'string'],
|
||||||
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return redirect($reviewsUrl)->withErrors($validator)->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $validator->validated();
|
||||||
|
|
||||||
|
// Not $product->reviews()->create(...): that relation only exists via a
|
||||||
|
// Product::macro() registered in CorePlugin::register(Panel $panel), which
|
||||||
|
// Filament calls solely when the /boboko admin panel boots — never on a
|
||||||
|
// plain storefront request, where the macro is simply undefined.
|
||||||
|
$review = ProductReview::create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'rating' => $data['rating'],
|
||||||
|
'body' => $data['content'],
|
||||||
|
'reviewer_name' => $data['name'] ?? null,
|
||||||
|
'reviewer_email' => $data['email'],
|
||||||
|
'reviewed_at' => now(),
|
||||||
|
'source' => 'storefront',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ReviewServiceProvider also reindexes on the model's `created` event, but
|
||||||
|
// queued (SCOUT_QUEUE=true) — it wouldn't land before this redirect's page
|
||||||
|
// load. Syncing here skips our queue too, but Meilisearch's own write API
|
||||||
|
// is itself async on top of that (see mergeJustSubmittedReview()), so this
|
||||||
|
// alone still isn't a guarantee — it's the flash below that actually is.
|
||||||
|
$product->searchableSync();
|
||||||
|
|
||||||
|
return redirect($reviewsUrl)
|
||||||
|
->with('reviewSubmitted', true)
|
||||||
|
->with('justSubmittedReview', [
|
||||||
|
'id' => $review->id,
|
||||||
|
'product_id' => $review->product_id,
|
||||||
|
'title' => $review->title,
|
||||||
|
'body' => $review->body,
|
||||||
|
'rating' => $review->rating,
|
||||||
|
'reviewed_at' => $review->reviewed_at?->timestamp,
|
||||||
|
'reviewer_name' => $review->reviewer_name,
|
||||||
|
'reply' => null,
|
||||||
|
'replied_at' => null,
|
||||||
|
'media' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Catalog\ProductCard;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
use Modules\Core\Wishlist\Services\WishlistService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page rendering only — the toggle action itself lives in core
|
||||||
|
* (Modules\Core\Wishlist\Http\Controllers\WishlistController, route
|
||||||
|
* `wishlist.toggle`), since it needs no app-specific presentation.
|
||||||
|
*/
|
||||||
|
class WishlistController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WishlistService $wishlist,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account's wishlist page.
|
||||||
|
*/
|
||||||
|
public function index(string $locale, ProductService $products): View
|
||||||
|
{
|
||||||
|
return view('account.wishlist', ['products' => $this->products($products)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same list for a guest, from their cookie. Logged-in users are sent
|
||||||
|
* to the account version.
|
||||||
|
*/
|
||||||
|
public function guest(string $locale, ProductService $products): View|RedirectResponse
|
||||||
|
{
|
||||||
|
if (auth()->check()) {
|
||||||
|
return redirect()->route('account.wishlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('wishlist.guest', ['products' => $this->products($products)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product cards for the current wishlist, newest first. Products no longer
|
||||||
|
* in the search index (deleted, unpublished) are simply skipped.
|
||||||
|
*/
|
||||||
|
private function products(ProductService $products): Collection
|
||||||
|
{
|
||||||
|
return collect($this->wishlist->ids())
|
||||||
|
->map(fn (int $id) => $products->getById($id))
|
||||||
|
->filter()
|
||||||
|
->map(fn (array $product) => ['id' => $product['id'], ...ProductCard::fromIndexed($product)])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Rules\HCaptcha;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ContactRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'message' => ['required', 'string', 'max:5000'],
|
||||||
|
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a logged-in shopper places an order and their account has no saved
|
||||||
|
* address yet, the order's shipping address (and their name, if the profile
|
||||||
|
* has none) becomes the account's. Never overwrites anything already saved.
|
||||||
|
*
|
||||||
|
* Uses the order's own user, not Auth: OrderPlaced can fire from a payment
|
||||||
|
* webhook, where nobody is logged in. Picked up by listener discovery.
|
||||||
|
*/
|
||||||
|
class SaveAddressFromFirstOrder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CustomerAccountService $account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(OrderPlaced $event): void
|
||||||
|
{
|
||||||
|
$order = $event->order;
|
||||||
|
$user = $order->user;
|
||||||
|
$shipping = $order->shippingAddress;
|
||||||
|
|
||||||
|
if (! $user || ! $shipping || ! $shipping->line_one) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = $this->account->customer($user);
|
||||||
|
|
||||||
|
if (! $customer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $customer->first_name && ! $customer->last_name) {
|
||||||
|
$this->account->updateProfile($user, [
|
||||||
|
'first_name' => $shipping->first_name,
|
||||||
|
'last_name' => $shipping->last_name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collect($this->account->addresses($user))->isNotEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->account->createAddress($user, [
|
||||||
|
...$shipping->only(['first_name', 'last_name', 'line_one', 'city', 'state', 'postcode', 'country_id', 'contact_phone']),
|
||||||
|
'contact_email' => $user->email,
|
||||||
|
'shipping_default' => true,
|
||||||
|
'billing_default' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent to whoever submitted the contact form. Deliberately generic: it never
|
||||||
|
* repeats what they wrote, so the form can't be used to deliver arbitrary text
|
||||||
|
* to someone else's inbox.
|
||||||
|
*/
|
||||||
|
class ContactConfirmationMail extends Mailable
|
||||||
|
{
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(subject: __('storefront.contact.confirmation_subject'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'emails.contact-confirmation');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Address;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A contact-form message, sent to the store's CONTACT_EMAIL. Reply-To is the
|
||||||
|
* sender, so replying from the inbox goes straight to them.
|
||||||
|
*/
|
||||||
|
class ContactMessageMail extends Mailable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $senderName,
|
||||||
|
public readonly string $senderEmail,
|
||||||
|
public readonly string $body,
|
||||||
|
public readonly string $senderLocale,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
replyTo: [new Address($this->senderEmail, $this->senderName)],
|
||||||
|
subject: "Νέο μήνυμα επικοινωνίας από {$this->senderName}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'emails.contact-message');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,6 @@ class User extends Authenticatable implements LunarUserInterface
|
|||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
protected $dispatchesEvents = [
|
|
||||||
'created' => UserCreated::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the `h-captcha-response` token the hCaptcha widget adds to a form.
|
||||||
|
* Use it as `'h-captcha-response' => ['required', new HCaptcha]`.
|
||||||
|
*
|
||||||
|
* Fails closed: if hCaptcha can't be reached the submission is rejected, since
|
||||||
|
* letting it through would reopen the hole this exists to close (bots making
|
||||||
|
* us send email to arbitrary addresses).
|
||||||
|
*/
|
||||||
|
class HCaptcha implements ValidationRule
|
||||||
|
{
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
$fail(__('storefront.auth.captcha_failed'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::asForm()
|
||||||
|
->timeout(5)
|
||||||
|
->post('https://api.hcaptcha.com/siteverify', [
|
||||||
|
'secret' => config('services.hcaptcha.secret'),
|
||||||
|
'response' => $value,
|
||||||
|
// Rejects tokens solved against someone else's sitekey.
|
||||||
|
'sitekey' => config('services.hcaptcha.sitekey'),
|
||||||
|
'remoteip' => request()->ip(),
|
||||||
|
]);
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
Log::warning('hCaptcha siteverify unreachable', ['error' => $e->getMessage()]);
|
||||||
|
$fail(__('storefront.auth.captcha_failed'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response->successful() || $response->json('success') !== true) {
|
||||||
|
// A bad/missing secret or sitekey would otherwise look like every
|
||||||
|
// shopper failing the captcha.
|
||||||
|
$configErrors = array_intersect((array) $response->json('error-codes'), [
|
||||||
|
'missing-input-secret',
|
||||||
|
'invalid-input-secret',
|
||||||
|
'sitekey-secret-mismatch',
|
||||||
|
'invalid-sitekey',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed() || $configErrors) {
|
||||||
|
Log::warning('hCaptcha siteverify error', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'error-codes' => $response->json('error-codes'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fail(__('storefront.auth.captcha_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,18 @@
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
// Laravel's priority list would otherwise run `auth` before core's
|
||||||
|
// `locale` middleware, so the redirects below would build URLs before
|
||||||
|
// URL::defaults(['locale' => …]) is set, throwing a missing-parameter error.
|
||||||
|
$middleware->prependToPriorityList(
|
||||||
|
before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
|
||||||
|
prepend: \Modules\Core\Localization\Middleware\LocaleMiddleware::class,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both resolve inside the {locale} group, after the `locale` middleware
|
||||||
|
// has set URL::defaults(['locale' => …]), so route() needs no locale arg.
|
||||||
|
$middleware->redirectGuestsTo(fn () => route('login'));
|
||||||
|
$middleware->redirectUsersTo(fn () => route('home'));
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Modules\Core\Catalog\Recommendations\RandomRule;
|
||||||
|
use Modules\Core\Catalog\Recommendations\SameCategoryRule;
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Product recommendation rules
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Tried in order by Modules\Core\Catalog\Services\RecommendationService —
|
||||||
|
| the first rule that returns at least one product wins. The order here IS
|
||||||
|
| the fallback chain: SameCategoryRule first, then RandomRule as a
|
||||||
|
| last-resort so a product page is never left with zero recommendations
|
||||||
|
| (as long as the store has more than one product). A consuming app can
|
||||||
|
| reorder, add, or remove rules freely — nothing about the chain shape is
|
||||||
|
| hardcoded in the service itself.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'recommendation_rules' => [
|
||||||
|
SameCategoryRule::class,
|
||||||
|
RandomRule::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Per-site settings for the cart + checkout module (see
|
||||||
|
* Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable —
|
||||||
|
* artisan vendor:publish --tag=core-config.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Name of the storefront's login route. The checkout's login tab and the
|
||||||
|
* confirmation page link to it with `?redirect=<checkout path>`, so the
|
||||||
|
* login page must send the shopper back there afterwards. null: no login
|
||||||
|
* offered in checkout at all.
|
||||||
|
*/
|
||||||
|
'login_route' => 'login',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Name of the storefront's product-listing route — where confirmation()
|
||||||
|
* redirects a visit with no placed order to look at (session expired,
|
||||||
|
* direct navigation, a bookmark). route($this, $locale) must resolve.
|
||||||
|
*/
|
||||||
|
'products_route' => 'products',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ISO 3166-1 alpha-3 code fixing checkout to a single country (a hidden
|
||||||
|
* field, forced server-side — no country picker shown at all). null (the
|
||||||
|
* default) gives the full country/region picker, for a multi-country
|
||||||
|
* store. 3dealer is Greece-only for now.
|
||||||
|
*/
|
||||||
|
'store_country_iso3' => 'GRC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The `purpose` tag CartController expects a product custom field's
|
||||||
|
* `file` answer to already carry (see Modules\Core\File\Models\File) —
|
||||||
|
* matches whatever purpose string the host's own upload endpoint
|
||||||
|
* (extending Modules\Core\File\Http\Controllers\UploadFileController)
|
||||||
|
* tags its stored files with. This module never reaches into that
|
||||||
|
* host controller directly; this config value is the one shared
|
||||||
|
* source of truth between the two.
|
||||||
|
*/
|
||||||
|
'custom_field_upload_purpose' => 'custom-field-upload',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -16,4 +16,126 @@
|
|||||||
|
|
||||||
'auto_create_customer_for_user' => true,
|
'auto_create_customer_for_user' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Privacy / GDPR data-subject requests
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
|
||||||
|
| that should be consulted for right-of-access/right-of-erasure requests. A
|
||||||
|
| module never needs to be known to core in advance — it just adds its own
|
||||||
|
| provider class here, the same way config('lunar.search.indexers') maps a
|
||||||
|
| model to its indexer. See docs/privacy.md.
|
||||||
|
|
|
||||||
|
| 'grace_period_days' is how long an erasure request stays cancellable
|
||||||
|
| (account deactivated, not yet erased) before it's actually processed by
|
||||||
|
| the privacy:process-erasure-requests scheduled command.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'privacy' => [
|
||||||
|
'providers' => [
|
||||||
|
// ActivityLogDataProvider MUST run before AddressDataProvider —
|
||||||
|
// it resolves which activity_log rows belong to this customer
|
||||||
|
// (including ones keyed by an Address id) before
|
||||||
|
// AddressDataProvider hard-deletes those Address rows. See that
|
||||||
|
// provider's own class docblock.
|
||||||
|
\Modules\Core\Logging\Privacy\ActivityLogDataProvider::class,
|
||||||
|
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
|
||||||
|
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
|
||||||
|
\Modules\Core\Order\Privacy\OrderDataProvider::class,
|
||||||
|
\Modules\Core\Cart\Privacy\CartDataProvider::class,
|
||||||
|
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
|
||||||
|
\Modules\Core\Payment\Privacy\PaymentDataProvider::class,
|
||||||
|
\Modules\Core\Auth\Privacy\UserSessionDataProvider::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'grace_period_days' => 30,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cart Abandonment Threshold
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| How long a cart (that hasn't converted to a placed order) can go without
|
||||||
|
| activity before Modules\Core\Cart\Filament\Resources\CartResource treats
|
||||||
|
| it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString()
|
||||||
|
| accepts works, e.g. '1 hour', '30 minutes', '2 days'.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cart' => [
|
||||||
|
'abandoned_after' => '1 hour',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|----------------------------------------------------------------------
|
||||||
|
| Unrecoverable Cap
|
||||||
|
|----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Beyond this age, a stale cart stops being treated as an active
|
||||||
|
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
|
||||||
|
| CartLifecycleService) — too old to be a realistic recovery target
|
||||||
|
| (pricing/stock/tax likely stale by then). This is about the
|
||||||
|
| abandoned-cart pipeline only, not data retention — no rows are
|
||||||
|
| deleted or pruned based on this value.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'unrecoverable_after' => '90 days',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Order Return Window
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| How many days after a carrier order is delivered (Order::fulfillment_status
|
||||||
|
| becomes 'return_window_open') before Modules\Core\Order\Commands\
|
||||||
|
| CloseExpiredReturnWindows auto-completes it, if no return was requested.
|
||||||
|
| Store-pickup orders have no return-window step and are unaffected by
|
||||||
|
| this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'order' => [
|
||||||
|
'return_window_days' => 14,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Storefront OTP Login
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Modules\Core\Auth\Services\UserOtpService's passwordless login.
|
||||||
|
| max_attempts caps how many wrong codes a shopper can guess against ONE
|
||||||
|
| generated code before it's invalidated outright. generation_limit/
|
||||||
|
| generation_decay_minutes cap how often a NEW code can be requested for
|
||||||
|
| the same email — independent of max_attempts, since generating a fresh
|
||||||
|
| code also resets the guess count, so an attempt cap alone doesn't stop
|
||||||
|
| an attacker from just requesting a new code every few tries. This same
|
||||||
|
| limit is also what stands between a malicious/careless caller and
|
||||||
|
| mail-bombing one inbox.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'auth' => [
|
||||||
|
'otp' => [
|
||||||
|
'max_attempts' => 5,
|
||||||
|
'generation_limit' => 3,
|
||||||
|
'generation_decay_minutes' => 10,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Modules\Core\Customer\Services\CustomerEmailChangeService — same
|
||||||
|
// shape/reasoning as auth.otp above, independent limits since this
|
||||||
|
// is a separate flow (changing an existing account's login email,
|
||||||
|
// not logging in).
|
||||||
|
'email_change' => [
|
||||||
|
'max_attempts' => 5,
|
||||||
|
'generation_limit' => 3,
|
||||||
|
'generation_decay_minutes' => 10,
|
||||||
|
'expiry_minutes' => 10,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -58,11 +58,23 @@
|
|||||||
| make sense for the store you're building.
|
| make sense for the store you're building.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'draft_status' => 'awaiting-payment',
|
'draft_status' => 'awaiting_payment',
|
||||||
|
|
||||||
|
// Two sequences — carrier and store-pickup — see Modules\Core\Order\
|
||||||
|
// Services\OrderStatusFlow, the single source of truth for which
|
||||||
|
// transitions are actually offered from a given status. Payment
|
||||||
|
// timing (prepaid vs. cash-on-delivery) does not affect this
|
||||||
|
// sequence at all — see Order::paid/paid_at instead, an entirely
|
||||||
|
// independent field with no status value of its own.
|
||||||
|
//
|
||||||
|
// 'favourite' controls whether a status gets its own tab in the order
|
||||||
|
// list topbar (Lunar\Admin\...\ListOrders::getDefaultTabs()) — set
|
||||||
|
// only on the main-journey statuses so the topbar isn't cluttered
|
||||||
|
// with every branch/exception value; every status (favourite or not)
|
||||||
|
// remains reachable via the table's own status filter/search.
|
||||||
'statuses' => [
|
'statuses' => [
|
||||||
|
|
||||||
'awaiting-payment' => [
|
'awaiting_payment' => [
|
||||||
'label' => 'Awaiting Payment',
|
'label' => 'Awaiting Payment',
|
||||||
'color' => '#848a8c',
|
'color' => '#848a8c',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
@@ -70,17 +82,25 @@
|
|||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
'payment-offline' => [
|
'processing' => [
|
||||||
'label' => 'Payment Offline',
|
'label' => 'Processing',
|
||||||
'color' => '#0A81D7',
|
'color' => '#0A81D7',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
'notifications' => [],
|
'notifications' => [],
|
||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
'payment-received' => [
|
'ready_for_dispatch' => [
|
||||||
'label' => 'Payment Received',
|
'label' => 'Ready for Dispatch',
|
||||||
'color' => '#6a67ce',
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'ready_for_pickup' => [
|
||||||
|
'label' => 'Ready for Pickup',
|
||||||
|
'color' => '#c98a2e',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
'notifications' => [],
|
'notifications' => [],
|
||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
@@ -88,11 +108,78 @@
|
|||||||
|
|
||||||
'dispatched' => [
|
'dispatched' => [
|
||||||
'label' => 'Dispatched',
|
'label' => 'Dispatched',
|
||||||
|
'color' => '#0A81D7',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
'notifications' => [],
|
'notifications' => [],
|
||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'delivery_failed' => [
|
||||||
|
'label' => 'Delivery Failed',
|
||||||
|
'color' => '#c0392b',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'picked_up' => [
|
||||||
|
'label' => 'Picked Up',
|
||||||
|
'color' => '#3f7a5c',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// The parcel arriving and the return window opening are the same
|
||||||
|
// moment — one status value covers both.
|
||||||
|
'delivered' => [
|
||||||
|
'label' => 'Delivered',
|
||||||
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'completed' => [
|
||||||
|
'label' => 'Completed',
|
||||||
|
'color' => '#3f7a5c',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'return_requested' => [
|
||||||
|
'label' => 'Return Requested',
|
||||||
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'returned' => [
|
||||||
|
'label' => 'Returned',
|
||||||
|
'color' => '#c0392b',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'partially_refunded' => [
|
||||||
|
'label' => 'Partially Refunded',
|
||||||
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'refunded' => [
|
||||||
|
'label' => 'Refunded',
|
||||||
|
'color' => '#c0392b',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -46,6 +46,6 @@
|
|||||||
| to include in the count below.
|
| to include in the count below.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'order_count_statuses' => ['payment-received'],
|
'order_count_statuses' => ['processing'],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,7 +20,17 @@
|
|||||||
Lunar\Models\Collection::class,
|
Lunar\Models\Collection::class,
|
||||||
Lunar\Models\Customer::class,
|
Lunar\Models\Customer::class,
|
||||||
Lunar\Models\Order::class,
|
Lunar\Models\Order::class,
|
||||||
Lunar\Models\Product::class,
|
// Modules\Core\Catalog\Models\Product, not Lunar\Models\Product —
|
||||||
|
// both share the same underlying Meilisearch index name, so
|
||||||
|
// listing the base class here too would make every reindex
|
||||||
|
// (this default list is always merged in, even when a specific
|
||||||
|
// model is passed on the CLI — see Lunar\Console\Commands\
|
||||||
|
// ScoutIndexerCommand::handle()) run the base class's indexing a
|
||||||
|
// second time right after the subclass's, silently overwriting
|
||||||
|
// every document with one missing custom_fields/order_count
|
||||||
|
// (the whole reason Modules\Core\Catalog\Models\Product exists —
|
||||||
|
// see its own docblock). Caught in practice.
|
||||||
|
Modules\Core\Catalog\Models\Product::class,
|
||||||
Lunar\Models\ProductOption::class,
|
Lunar\Models\ProductOption::class,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -49,6 +59,20 @@
|
|||||||
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
|
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
|
||||||
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
|
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
|
||||||
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
|
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
|
||||||
|
// Lunar\Models\Product::class, NOT Modules\Core\Catalog\Models\
|
||||||
|
// Product::class — Lunar\Base\Traits\Searchable::indexer() (and
|
||||||
|
// getFilterableAttributes()/getSortableAttributes(), same trait)
|
||||||
|
// reads `$config[self::class]`, and `self::class` inside a TRAIT
|
||||||
|
// METHOD is a compile-time literal bound to whichever class first
|
||||||
|
// `use`s the trait — Lunar\Models\Product, since the subclass
|
||||||
|
// never re-declares indexer() itself — regardless of which
|
||||||
|
// instance actually calls the method at runtime. Keying this by
|
||||||
|
// the subclass here made the lookup miss entirely, silently
|
||||||
|
// falling back to Lunar\Search\ScoutIndexer's own near-empty
|
||||||
|
// filterable/sortable field list — confirmed live: it wiped every
|
||||||
|
// real filterable/sortable attribute the index had (including
|
||||||
|
// ones that already worked, like collection_ids), not just the
|
||||||
|
// new order_count one. Caught in practice, reverted.
|
||||||
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
|
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
|
||||||
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class,
|
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -35,9 +35,36 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Where contact-form messages are sent (ContactController).
|
||||||
|
'contact' => [
|
||||||
|
'email' => env('CONTACT_EMAIL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For
|
||||||
|
// local dev use hCaptcha's test keys, the real ones reject localhost:
|
||||||
|
// sitekey 10000000-ffff-ffff-ffff-000000000001,
|
||||||
|
// secret 0x0000000000000000000000000000000000000000.
|
||||||
|
'hcaptcha' => [
|
||||||
|
'sitekey' => env('HCAPTCHA_SITEKEY'),
|
||||||
|
'secret' => env('HCAPTCHA_SECRET'),
|
||||||
|
],
|
||||||
|
|
||||||
'stoic' => [
|
'stoic' => [
|
||||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||||
'host' => env('STOIC_HOST'),
|
'host' => env('STOIC_HOST'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Keys read by lunarphp/stripe + Modules\Core\Payment\Drivers\StripePaymentDriver.
|
||||||
|
// `key` is the SECRET key (this ecosystem's convention — StripeManager calls
|
||||||
|
// Stripe::setApiKey(config('services.stripe.key'))); `public_key` is the
|
||||||
|
// publishable key for Stripe.js; `webhooks.lunar` is the signing secret the
|
||||||
|
// webhook route verifies against.
|
||||||
|
'stripe' => [
|
||||||
|
'key' => env('STRIPE_SECRET'),
|
||||||
|
'public_key' => env('STRIPE_PUBLIC_KEY'),
|
||||||
|
'webhooks' => [
|
||||||
|
'lunar' => env('STRIPE_WEBHOOK_SECRET'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| ACS Courier credentials
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
|
||||||
|
| HTTP header (gates the REST gateway itself) and four account fields
|
||||||
|
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
|
||||||
|
| request body. Both are supplied by ACS when your account is set up.
|
||||||
|
|
|
||||||
|
| Set these via environment variables — never commit real values.
|
||||||
|
|
|
||||||
|
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
|
||||||
|
| every ACSAlias call).
|
||||||
|
| ACS_API_KEY The AcsApiKey header value.
|
||||||
|
| ACS_COMPANY_ID Company_ID body field.
|
||||||
|
| ACS_COMPANY_PASSWORD Company_Password body field.
|
||||||
|
| ACS_USER_ID User_ID body field.
|
||||||
|
| ACS_USER_PASSWORD User_Password body field.
|
||||||
|
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
|
||||||
|
| calculation and voucher creation.
|
||||||
|
| ACS_SENDER_* Static sender details reused on every voucher.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'base_url' => env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
|
||||||
|
|
||||||
|
'api_key' => env('ACS_API_KEY'),
|
||||||
|
|
||||||
|
'company_id' => env('ACS_COMPANY_ID'),
|
||||||
|
'company_password' => env('ACS_COMPANY_PASSWORD'),
|
||||||
|
'user_id' => env('ACS_USER_ID'),
|
||||||
|
'user_password' => env('ACS_USER_PASSWORD'),
|
||||||
|
|
||||||
|
'billing_code' => env('ACS_BILLING_CODE'),
|
||||||
|
|
||||||
|
'sender' => [
|
||||||
|
'name' => env('ACS_SENDER_NAME'),
|
||||||
|
'address' => env('ACS_SENDER_ADDRESS'),
|
||||||
|
'zip_code' => env('ACS_SENDER_ZIP'),
|
||||||
|
'phone' => env('ACS_SENDER_PHONE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'timeout' => env('ACS_HTTP_TIMEOUT', 10),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Box Now credentials
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
|
||||||
|
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
|
||||||
|
| ~1hr expiry), then attach it as an Authorization header on every call.
|
||||||
|
| Unlike ACS, there is no separate per-request credential body — the
|
||||||
|
| token alone authorizes all calls once obtained.
|
||||||
|
|
|
||||||
|
| Set these via environment variables — never commit real values.
|
||||||
|
|
|
||||||
|
| Box Now has two environments (see their Partner API manual, section 2):
|
||||||
|
| Stage/Sandbox for testing, Production once live. Each has its own
|
||||||
|
| client_id/client_secret pair and its own base_url/location_api_url —
|
||||||
|
| there is no shared "switch an env var" flag, since stage credentials
|
||||||
|
| don't work against the production host or vice versa.
|
||||||
|
|
|
||||||
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
|
||||||
|
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
|
||||||
|
| lookups (Box Now recommends this over the main
|
||||||
|
| base URL for those two calls specifically).
|
||||||
|
| BOXNOW_CLIENT_ID OAuth2 client id.
|
||||||
|
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
|
||||||
|
| BOXNOW_PARTNER_ID Numeric partnerId Box Now issues alongside your
|
||||||
|
| credentials. NOT used for REST API authentication
|
||||||
|
| (BoxNowClient authenticates with client_id/
|
||||||
|
| client_secret alone) — this is only consumed by
|
||||||
|
| the client-side Destination Map widget config
|
||||||
|
| (_bn_map_widget_config.partnerId), confirmed
|
||||||
|
| against Box Now's own WooCommerce plugin source.
|
||||||
|
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
|
||||||
|
| the pickup origin on every delivery request.
|
||||||
|
| BOXNOW_SENDER_* Static sender contact details reused on every
|
||||||
|
| delivery request.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'base_url' => env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
|
||||||
|
'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
|
||||||
|
|
||||||
|
'client_id' => env('BOXNOW_CLIENT_ID'),
|
||||||
|
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
|
||||||
|
'partner_id' => env('BOXNOW_PARTNER_ID'),
|
||||||
|
|
||||||
|
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
|
||||||
|
|
||||||
|
'sender' => [
|
||||||
|
'name' => env('BOXNOW_SENDER_NAME'),
|
||||||
|
'email' => env('BOXNOW_SENDER_EMAIL'),
|
||||||
|
'phone' => env('BOXNOW_SENDER_PHONE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Modules\Core\Localization\Services\TranslationService;
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default `validation` translation lines — Laravel's own error-message group
|
||||||
|
* (`validation.required`, `validation.email`, `validation.attributes.*`, …),
|
||||||
|
* resolved by every `Validator::make()`/`$request->validate()` call in the app
|
||||||
|
* (see CheckoutController::saveAddress(), CartController, ProductController),
|
||||||
|
* not just the checkout module.
|
||||||
|
*
|
||||||
|
* Spatie's DB loader (spatie/laravel-translation-loader, wired in boboko-core's
|
||||||
|
* LocalizationServiceProvider) merges this group over Laravel's file-based
|
||||||
|
* validation.php, which the project doesn't ship a `lang/` copy of — so without
|
||||||
|
* this, Greek requests fall back to Laravel's untranslated English defaults.
|
||||||
|
* Only the rule keys and field attributes actually in use are seeded; add more
|
||||||
|
* as new rules/fields show up.
|
||||||
|
*
|
||||||
|
* Additive and idempotent: a key that already exists is left untouched, so
|
||||||
|
* anything edited in the Filament Language Lines UI wins on a re-run. Runs
|
||||||
|
* explicitly — `php artisan db:seed --class=ValidationTranslationsSeeder` — it
|
||||||
|
* is not wired into DatabaseSeeder.
|
||||||
|
*
|
||||||
|
* Greek copy uses the project's informal register (εσύ/σου).
|
||||||
|
*/
|
||||||
|
class ValidationTranslationsSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$translations = app(TranslationService::class);
|
||||||
|
|
||||||
|
foreach ($this->lines() as $key => [$en, $el]) {
|
||||||
|
$exists = LanguageLine::query()
|
||||||
|
->where('group', 'validation')
|
||||||
|
->where('key', $key)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->command?->warn("validation.{$key} already exists — skipped");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$translations->create('validation', $key, ['en' => $en, 'el' => $el]);
|
||||||
|
$this->command?->info("validation.{$key} added");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* key => [English, Greek].
|
||||||
|
*
|
||||||
|
* @return array<string, array{0: string, 1: string}>
|
||||||
|
*/
|
||||||
|
private function lines(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// ── Rule messages ─────────────────────────────────────────────
|
||||||
|
'required' => ['The :attribute field is required.', 'Το πεδίο :attribute είναι υποχρεωτικό.'],
|
||||||
|
'email' => ['The :attribute field must be a valid email address.', 'Το πεδίο :attribute πρέπει να είναι έγκυρη διεύθυνση email.'],
|
||||||
|
'string' => ['The :attribute field must be a string.', 'Το πεδίο :attribute πρέπει να είναι κείμενο.'],
|
||||||
|
'integer' => ['The :attribute field must be an integer.', 'Το πεδίο :attribute πρέπει να είναι ακέραιος αριθμός.'],
|
||||||
|
'boolean' => ['The :attribute field must be true or false.', 'Το πεδίο :attribute πρέπει να είναι true ή false.'],
|
||||||
|
'min.numeric' => ['The :attribute field must be at least :min.', 'Το πεδίο :attribute πρέπει να είναι τουλάχιστον :min.'],
|
||||||
|
'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'],
|
||||||
|
'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'],
|
||||||
|
'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'],
|
||||||
|
// Product custom-field photo uploads (CustomFieldUploadController, CartController).
|
||||||
|
'file' => ['The :attribute field must be a file.', 'Το πεδίο :attribute πρέπει να είναι αρχείο.'],
|
||||||
|
'mimes' => ['The :attribute field must be a file of type: :values.', 'Το πεδίο :attribute πρέπει να είναι αρχείο τύπου: :values.'],
|
||||||
|
'max.file' => ['The :attribute field must not be greater than :max kilobytes.', 'Το αρχείο στο πεδίο :attribute δεν πρέπει να ξεπερνά τα :max kilobytes.'],
|
||||||
|
'uploaded' => ['The :attribute failed to upload.', 'Η μεταφόρτωση στο πεδίο :attribute απέτυχε.'],
|
||||||
|
|
||||||
|
// ── Field names (checkout: billing/shipping address) ──────────
|
||||||
|
'attributes.contact_email' => ['email', 'email'],
|
||||||
|
'attributes.billing_first_name' => ['first name', 'όνομα'],
|
||||||
|
'attributes.billing_last_name' => ['last name', 'επώνυμο'],
|
||||||
|
'attributes.billing_company_name' => ['company name', 'επωνυμία εταιρείας'],
|
||||||
|
'attributes.billing_tax_identifier' => ['tax ID', 'ΑΦΜ'],
|
||||||
|
'attributes.billing_line_one' => ['address', 'διεύθυνση'],
|
||||||
|
'attributes.billing_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
|
||||||
|
'attributes.billing_city' => ['city', 'πόλη'],
|
||||||
|
'attributes.billing_state' => ['region', 'νομό / περιοχή'],
|
||||||
|
'attributes.billing_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
|
||||||
|
'attributes.billing_country_id' => ['country', 'χώρα'],
|
||||||
|
'attributes.billing_contact_phone' => ['phone', 'τηλέφωνο'],
|
||||||
|
'attributes.shipping_first_name' => ['first name', 'όνομα'],
|
||||||
|
'attributes.shipping_last_name' => ['last name', 'επώνυμο'],
|
||||||
|
'attributes.shipping_company_name' => ['company name', 'επωνυμία εταιρείας'],
|
||||||
|
'attributes.shipping_line_one' => ['address', 'διεύθυνση'],
|
||||||
|
'attributes.shipping_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
|
||||||
|
'attributes.shipping_city' => ['city', 'πόλη'],
|
||||||
|
'attributes.shipping_state' => ['region', 'νομό / περιοχή'],
|
||||||
|
'attributes.shipping_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
|
||||||
|
'attributes.shipping_country_id' => ['country', 'χώρα'],
|
||||||
|
'attributes.shipping_contact_phone' => ['phone', 'τηλέφωνο'],
|
||||||
|
'attributes.shipping_delivery_instructions' => ['delivery notes', 'σχόλια για την παράδοση'],
|
||||||
|
|
||||||
|
// ── Field names (cart) ─────────────────────────────────────────
|
||||||
|
'attributes.purchasable_id' => ['product', 'προϊόν'],
|
||||||
|
'attributes.quantity' => ['quantity', 'ποσότητα'],
|
||||||
|
'attributes.code' => ['coupon code', 'κωδικό κουπονιού'],
|
||||||
|
|
||||||
|
// ── Field names (product reviews / stock check) ────────────────
|
||||||
|
'attributes.variant' => ['variant', 'παραλλαγή'],
|
||||||
|
'attributes.rating' => ['rating', 'βαθμολογία'],
|
||||||
|
'attributes.content' => ['review text', 'κείμενο κριτικής'],
|
||||||
|
'attributes.name' => ['name', 'όνομα'],
|
||||||
|
'attributes.email' => ['email', 'email'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,3 +10,7 @@ services:
|
|||||||
scheduler:
|
scheduler:
|
||||||
volumes:
|
volumes:
|
||||||
- ../boboko-core:/var/www/boboko-core
|
- ../boboko-core:/var/www/boboko-core
|
||||||
|
|
||||||
|
vite:
|
||||||
|
volumes:
|
||||||
|
- ../boboko-core:/boboko-core
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Mirrors entrypoint.sh's Composer block: node_modules is a named/anonymous
|
||||||
|
# volume, not the bind-mounted host directory, so this container needs its
|
||||||
|
# own live install on every boot rather than relying on whatever was baked in
|
||||||
|
# at image-build time.
|
||||||
|
echo "[entrypoint-vite] Installing npm dependencies..."
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Re-resolve @boboko/core specifically on every boot, whether it's a local
|
||||||
|
# path-repo checkout (file:../boboko-core, needs docker-compose.core-dev.yml's
|
||||||
|
# ../boboko-core:/boboko-core mount) or a tagged VCS install
|
||||||
|
# (git+https://...#vX.Y.Z) — the same manual toggle composer.json's
|
||||||
|
# repositories/_repositories block uses for the PHP side of this same
|
||||||
|
# package. A plain `npm install` above only installs what package-lock.json
|
||||||
|
# already pins; it won't notice a new commit on the path-repo checkout or a
|
||||||
|
# re-pushed tag. `npm update` re-resolves that one package against whatever
|
||||||
|
# package.json currently says and rewrites package-lock.json to pin it —
|
||||||
|
# exactly like `composer update boboko/* --with-all-dependencies` rewrites
|
||||||
|
# composer.lock, which is committed so prod picks up the resolved version via
|
||||||
|
# `npm install` alone (no `npm update` in prod's build).
|
||||||
|
echo "[entrypoint-vite] Re-resolving @boboko/core..."
|
||||||
|
npm update @boboko/core
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "boboko-starter",
|
"name": "app",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -7,9 +7,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"@phosphor-icons/web": "^2.1.2",
|
|
||||||
"axios": "^1.15.2",
|
"axios": "^1.15.2",
|
||||||
"flatpickr": "^4.6.13"
|
"leaflet": "^1.9.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -147,12 +146,6 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@phosphor-icons/web": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||||
@@ -1015,12 +1008,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatpickr": {
|
|
||||||
"version": "4.6.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
|
|
||||||
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
@@ -1256,6 +1243,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
|||||||
@@ -14,8 +14,14 @@
|
|||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"axios": "^1.15.2"
|
"axios": "^1.15.2",
|
||||||
|
"leaflet": "^1.9.4"
|
||||||
|
},
|
||||||
|
"_comment_boboko_core": "Mirrors composer.json's repositories/_repositories toggle: swap @boboko/core above with the line in _dependencies below (and back) by hand to switch between the local path-repo checkout and a tagged VCS install. The #semver:0.x range mirrors composer.json's own \"boboko/core\": \"0.*\" constraint exactly — note ^0.23.0 would NOT be equivalent here, since npm's caret locks the minor version too below 1.0.0 (>=0.23.0 <0.24.0). 0.x matches any 0.y.z tag, same as Composer's 0.*. npm reads the repo's git tags, filters to valid semver ones, and resolves the best match, so `npm update @boboko/core` alone picks up a newly pushed tag within range, no manual edit here per release. See docker/entrypoint-vite.sh and CONTRIBUTE.md.",
|
||||||
|
"_dependencies": {
|
||||||
|
"@boboko/core": "file:../boboko-core"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 116 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "3dealer",
|
||||||
|
"short_name": "3dealer",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -1,6 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "./fonts.css";
|
@import "./fonts.css";
|
||||||
@import "./dropdown.css";
|
@import "./dropdown.css";
|
||||||
|
|
||||||
|
/* Note for anyone theming the checkout module (resources/css/checkout.css,
|
||||||
|
.bbk-* classes): it's deliberately plain, unlayered CSS, not inside any
|
||||||
|
@layer — so override it with plain rules here too, not from inside
|
||||||
|
@layer components/utilities, which would lose to it. See checkout.css's
|
||||||
|
file-level comment for why. */
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
@source '../**/*.blade.php';
|
@source '../**/*.blade.php';
|
||||||
@@ -325,7 +331,7 @@ @layer components {
|
|||||||
transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86);
|
transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover::after {
|
.btn-primary:not(:disabled):hover::after {
|
||||||
transform: translate(0, 0);
|
transform: translate(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ window.Turbo.session.drive = false;
|
|||||||
|
|
||||||
import { Application } from "@hotwired/stimulus";
|
import { Application } from "@hotwired/stimulus";
|
||||||
import { registerControllers } from "./stimulus/index";
|
import { registerControllers } from "./stimulus/index";
|
||||||
|
import { registerCheckout, registerWishlist } from "@boboko/core";
|
||||||
|
|
||||||
const application = Application.start();
|
const application = Application.start();
|
||||||
application.debug = false;
|
application.debug = false;
|
||||||
|
|
||||||
registerControllers(application);
|
registerControllers(application);
|
||||||
|
|
||||||
|
// Portable cart + checkout + wishlist modules, installed as a real npm
|
||||||
|
// dependency (see package.json). Each owns its own Stimulus controllers;
|
||||||
|
// these are the only wiring lines they need here.
|
||||||
|
registerCheckout(application);
|
||||||
|
registerWishlist(application);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
// 3dealer-side glue for the checkout module. The module owns the cart and emits
|
||||||
|
// `bbk-cart:updated` {count, total} on window after every change; this reflects
|
||||||
|
// the line count on the header bag icon. How (or whether) that count is shown
|
||||||
|
// is the host's call — hence this lives here, not in the module.
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['badge']
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.onUpdate = (event) => this.render(event.detail?.count ?? 0)
|
||||||
|
window.addEventListener('bbk-cart:updated', this.onUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
window.removeEventListener('bbk-cart:updated', this.onUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header cart icon click — there's no separate cart page, the drawer IS
|
||||||
|
// the cart. `bbk-cart:open` is the module's own event, already listened
|
||||||
|
// for by bbk-cart-controller.
|
||||||
|
open() {
|
||||||
|
window.dispatchEvent(new CustomEvent('bbk-cart:open'))
|
||||||
|
}
|
||||||
|
|
||||||
|
render(count) {
|
||||||
|
if (!this.hasBadgeTarget) return
|
||||||
|
this.badgeTarget.textContent = String(count)
|
||||||
|
this.badgeTarget.hidden = count < 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
// One product custom field of type `file` (see x-product-custom-fields).
|
||||||
|
// Uploads the photo to the storefront's own endpoint (CustomFieldUploadController)
|
||||||
|
// as soon as it's picked, then writes the returned File row's id (boboko-core's
|
||||||
|
// Modules\Core\File\Models\File) into the hidden input the add-to-cart form
|
||||||
|
// actually submits — the checkout module never receives the file itself.
|
||||||
|
//
|
||||||
|
// While uploading, the file input is marked invalid via setCustomValidity(),
|
||||||
|
// so the browser's own form validation blocks add-to-cart until the id is in
|
||||||
|
// place. A failed upload clears the input, so `required` blocks it too.
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['file', 'reference', 'preview', 'error']
|
||||||
|
static values = {
|
||||||
|
url: String,
|
||||||
|
label: String,
|
||||||
|
uploadingMessage: String,
|
||||||
|
failedMessage: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.abortController?.abort()
|
||||||
|
this.revokePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload() {
|
||||||
|
this.reset()
|
||||||
|
|
||||||
|
const file = this.fileTarget.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
this.abortController = abortController
|
||||||
|
|
||||||
|
this.fileTarget.setCustomValidity(this.uploadingMessageValue)
|
||||||
|
this.fileTarget.setAttribute('aria-busy', 'true')
|
||||||
|
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('file', file)
|
||||||
|
body.append('label', this.labelValue)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.urlValue, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: abortController.signal,
|
||||||
|
})
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!response.ok || !data?.file_id) {
|
||||||
|
this.fail(data?.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.referenceTarget.value = data.file_id
|
||||||
|
this.showPreview(file)
|
||||||
|
} catch (error) {
|
||||||
|
// A newer pick superseded this upload — reset() already handled it.
|
||||||
|
if (error.name === 'AbortError') return
|
||||||
|
this.fail()
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) this.markIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.abortController?.abort()
|
||||||
|
this.referenceTarget.value = ''
|
||||||
|
this.errorTarget.hidden = true
|
||||||
|
this.markIdle()
|
||||||
|
this.revokePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(message) {
|
||||||
|
this.fileTarget.value = ''
|
||||||
|
this.errorTarget.textContent = message || this.failedMessageValue
|
||||||
|
this.errorTarget.hidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
markIdle() {
|
||||||
|
this.fileTarget.setCustomValidity('')
|
||||||
|
this.fileTarget.removeAttribute('aria-busy')
|
||||||
|
}
|
||||||
|
|
||||||
|
showPreview(file) {
|
||||||
|
this.previewUrl = URL.createObjectURL(file)
|
||||||
|
this.previewTarget.src = this.previewUrl
|
||||||
|
this.previewTarget.hidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats the browser can't render (HEIC outside Safari) — the file
|
||||||
|
// input's own filename is enough there.
|
||||||
|
hidePreview() {
|
||||||
|
this.previewTarget.hidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
revokePreview() {
|
||||||
|
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
|
||||||
|
this.previewUrl = null
|
||||||
|
this.previewTarget.removeAttribute('src')
|
||||||
|
this.previewTarget.hidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
import AppearController from './appear-controller'
|
import AppearController from './appear-controller'
|
||||||
import AutoSubmitController from './auto-submit-controller'
|
import AutoSubmitController from './auto-submit-controller'
|
||||||
import BackToTopController from './back-to-top-controller'
|
import BackToTopController from './back-to-top-controller'
|
||||||
|
import CartCountController from './cart-count-controller'
|
||||||
import CarouselController from './carousel-controller'
|
import CarouselController from './carousel-controller'
|
||||||
|
import CustomFieldUploadController from './custom-field-upload-controller'
|
||||||
import DropdownController from './dropdown-controller'
|
import DropdownController from './dropdown-controller'
|
||||||
import FrameScrollController from './frame-scroll-controller'
|
import FrameScrollController from './frame-scroll-controller'
|
||||||
import NavSearchController from './nav-search-controller'
|
import NavSearchController from './nav-search-controller'
|
||||||
@@ -15,13 +17,16 @@ import ProductGalleryController from './product-gallery-controller'
|
|||||||
import QuantityController from './quantity-controller'
|
import QuantityController from './quantity-controller'
|
||||||
import RangeSliderController from './range-slider-controller'
|
import RangeSliderController from './range-slider-controller'
|
||||||
import StarRatingController from './star-rating-controller'
|
import StarRatingController from './star-rating-controller'
|
||||||
|
import TabLinkController from './tab-link-controller'
|
||||||
import TabsController from './tabs-controller'
|
import TabsController from './tabs-controller'
|
||||||
|
|
||||||
export function registerControllers(application) {
|
export function registerControllers(application) {
|
||||||
application.register('appear', AppearController)
|
application.register('appear', AppearController)
|
||||||
application.register('auto-submit', AutoSubmitController)
|
application.register('auto-submit', AutoSubmitController)
|
||||||
application.register('back-to-top', BackToTopController)
|
application.register('back-to-top', BackToTopController)
|
||||||
|
application.register('cart-count', CartCountController)
|
||||||
application.register('carousel', CarouselController)
|
application.register('carousel', CarouselController)
|
||||||
|
application.register('custom-field-upload', CustomFieldUploadController)
|
||||||
application.register('dropdown', DropdownController)
|
application.register('dropdown', DropdownController)
|
||||||
application.register('frame-scroll', FrameScrollController)
|
application.register('frame-scroll', FrameScrollController)
|
||||||
application.register('nav-search', NavSearchController)
|
application.register('nav-search', NavSearchController)
|
||||||
@@ -30,5 +35,6 @@ export function registerControllers(application) {
|
|||||||
application.register('quantity', QuantityController)
|
application.register('quantity', QuantityController)
|
||||||
application.register('range-slider', RangeSliderController)
|
application.register('range-slider', RangeSliderController)
|
||||||
application.register('star-rating', StarRatingController)
|
application.register('star-rating', StarRatingController)
|
||||||
|
application.register('tab-link', TabLinkController)
|
||||||
application.register('tabs', TabsController)
|
application.register('tabs', TabsController)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,138 @@ import { Controller } from '@hotwired/stimulus'
|
|||||||
import { formatPrice } from '../utils/format-price'
|
import { formatPrice } from '../utils/format-price'
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ['price', 'image', 'swatch', 'colorName']
|
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError', 'submit']
|
||||||
static values = { variants: Array, selected: Number }
|
static values = {
|
||||||
|
variants: Array,
|
||||||
|
selected: Number,
|
||||||
|
stockCheckUrl: String,
|
||||||
|
// Two pre-rendered translated templates (see product/show.blade.php)
|
||||||
|
// rather than one — this controller doesn't reimplement Laravel's
|
||||||
|
// pluralization rules, it just picks whichever of these two the
|
||||||
|
// count actually needs and fills in the number.
|
||||||
|
stockErrorOne: String,
|
||||||
|
stockErrorMany: String,
|
||||||
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const urlId = parseInt(params.get('variant'))
|
const urlId = parseInt(params.get('variant'))
|
||||||
const defaultId = this.variantsValue[0]?.id
|
const urlVariant = this.variantsValue.find(v => v.id === urlId)
|
||||||
|
const initial = urlVariant ?? this.variantsValue[0]
|
||||||
|
|
||||||
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
|
// A product can have several independent options (e.g. size + style
|
||||||
? urlId
|
// + person-count) — this tracks the currently-picked value id per
|
||||||
: defaultId
|
// option handle, and selectVariant() below resolves the full
|
||||||
|
// combination back to one exact variant on every change.
|
||||||
|
this.selections = { ...initial?.options }
|
||||||
|
this.selectedValue = initial?.id
|
||||||
|
|
||||||
|
// Capture phase, on this controller's own root element (an ancestor
|
||||||
|
// of the checkout module's add-to-cart <form>) — runs BEFORE that
|
||||||
|
// form's own bubble-phase submit handler (bbk-add-to-cart#add), so a
|
||||||
|
// failed check can stop it from ever reaching the module at all. The
|
||||||
|
// module itself is never touched or modified for this: it keeps
|
||||||
|
// validating server-side regardless, this is purely an up-front,
|
||||||
|
// storefront-owned check (see [[project_checkout_module]] for why
|
||||||
|
// that split matters — stock UX is a catalog concern, not something
|
||||||
|
// the portable checkout module should own) — and a REAL, live check
|
||||||
|
// against the backend (ProductController::checkStock(), reading the
|
||||||
|
// Eloquent model directly), not page-load data that can go stale.
|
||||||
|
this.onSubmitCapture = this.checkStock.bind(this)
|
||||||
|
this.element.addEventListener('submit', this.onSubmitCapture, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.element.removeEventListener('submit', this.onSubmitCapture, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkStock(event) {
|
||||||
|
const form = event.target
|
||||||
|
if (!form.matches('.bbk-add-to-cart')) return
|
||||||
|
|
||||||
|
// The re-submit this itself triggers below, once the backend has
|
||||||
|
// confirmed the quantity is fine — let that one through to the
|
||||||
|
// module's own submit handler instead of checking a second time.
|
||||||
|
if (form.dataset.bbkStockChecked) {
|
||||||
|
delete form.dataset.bbkStockChecked
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
this.verifyStock(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyStock(form) {
|
||||||
|
this.clearStockError()
|
||||||
|
|
||||||
|
const submit = form.querySelector('[type="submit"]')
|
||||||
|
if (submit) submit.disabled = true
|
||||||
|
|
||||||
|
const purchasableId = form.querySelector('[data-bbk-purchasable-input]')?.value
|
||||||
|
const quantity = form.querySelector('[name="quantity"]')?.value || '1'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(this.stockCheckUrlValue, window.location.origin)
|
||||||
|
url.searchParams.set('variant', purchasableId)
|
||||||
|
url.searchParams.set('quantity', quantity)
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers: { Accept: 'application/json' } })
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!data.ok) {
|
||||||
|
this.showStockError(data.stock)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network hiccup — fall through and let the checkout module's
|
||||||
|
// own server-side check have the final word rather than
|
||||||
|
// silently blocking the shopper here.
|
||||||
|
} finally {
|
||||||
|
if (submit) submit.disabled = !this.selectedVariant?.inStock
|
||||||
|
}
|
||||||
|
|
||||||
|
form.dataset.bbkStockChecked = 'true'
|
||||||
|
form.requestSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
showStockError(available) {
|
||||||
|
if (!this.hasStockErrorTarget) return
|
||||||
|
|
||||||
|
this.stockErrorTarget.textContent = available === 1
|
||||||
|
? this.stockErrorOneValue
|
||||||
|
: this.stockErrorManyValue.replace(':count', String(available))
|
||||||
|
this.stockErrorTarget.hidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStockError() {
|
||||||
|
if (!this.hasStockErrorTarget) return
|
||||||
|
this.stockErrorTarget.hidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedVariant() {
|
||||||
|
return this.variantsValue.find(v => v.id === this.selectedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
selectVariant(event) {
|
selectVariant(event) {
|
||||||
const id = parseInt(event.currentTarget.dataset.variantId)
|
const option = event.currentTarget.dataset.option
|
||||||
this.selectedValue = id
|
const valueId = parseInt(event.currentTarget.dataset.valueId)
|
||||||
|
this.selections = { ...this.selections, [option]: valueId }
|
||||||
|
|
||||||
|
const match = this.variantsValue.find(variant =>
|
||||||
|
Object.keys(this.selections).every(key => variant.options?.[key] === this.selections[key])
|
||||||
|
)
|
||||||
|
|
||||||
|
// No variant exists for this combination (e.g. an option value that
|
||||||
|
// isn't offered together with another currently-selected value) —
|
||||||
|
// leave the previous selection in place rather than pointing the
|
||||||
|
// add-to-cart form at nothing.
|
||||||
|
if (!match) return
|
||||||
|
|
||||||
|
this.selectedValue = match.id
|
||||||
|
|
||||||
const url = new URL(window.location)
|
const url = new URL(window.location)
|
||||||
url.searchParams.set('variant', id)
|
url.searchParams.set('variant', match.id)
|
||||||
window.history.pushState({}, '', url)
|
window.history.pushState({}, '', url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +143,8 @@ export default class extends Controller {
|
|||||||
const variant = this.variantsValue.find(v => v.id === id)
|
const variant = this.variantsValue.find(v => v.id === id)
|
||||||
if (!variant) return
|
if (!variant) return
|
||||||
|
|
||||||
|
this.clearStockError()
|
||||||
|
|
||||||
if (this.hasPriceTarget && variant.price !== null) {
|
if (this.hasPriceTarget && variant.price !== null) {
|
||||||
this.priceTarget.textContent = formatPrice(variant.price)
|
this.priceTarget.textContent = formatPrice(variant.price)
|
||||||
}
|
}
|
||||||
@@ -38,13 +153,27 @@ export default class extends Controller {
|
|||||||
this.imageTarget.src = variant.image
|
this.imageTarget.src = variant.image
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the checkout module's add-to-cart form pointed at the chosen
|
||||||
|
// variant. [data-bbk-purchasable-input] is that module's documented
|
||||||
|
// hook (see resources/views/checkout/components/add-to-cart.blade.php);
|
||||||
|
// this is the one place the two touch.
|
||||||
|
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
|
||||||
|
if (purchasableInput) purchasableInput.value = id
|
||||||
|
|
||||||
|
// Out-of-stock variant (as of page load) can't be added at all.
|
||||||
|
if (this.hasSubmitTarget) this.submitTarget.disabled = !variant.inStock
|
||||||
|
|
||||||
this.swatchTargets.forEach(swatch => {
|
this.swatchTargets.forEach(swatch => {
|
||||||
const isSelected = parseInt(swatch.dataset.variantId) === id
|
const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId)
|
||||||
swatch.classList.toggle('is-selected', isSelected)
|
swatch.classList.toggle('is-selected', isSelected)
|
||||||
swatch.setAttribute('aria-pressed', String(isSelected))
|
swatch.setAttribute('aria-pressed', String(isSelected))
|
||||||
|
|
||||||
if (isSelected && this.hasColorNameTarget) {
|
if (isSelected) {
|
||||||
this.colorNameTarget.textContent = swatch.getAttribute('aria-label')
|
// Each color-option group has its own colorName echo (see
|
||||||
|
// x-ui.color-swatch) — matched by option handle so a swatch
|
||||||
|
// in one group never overwrites another group's label.
|
||||||
|
const colorName = this.colorNameTargets.find(target => target.dataset.option === swatch.dataset.option)
|
||||||
|
if (colorName) colorName.textContent = swatch.getAttribute('aria-label')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import { Controller } from '@hotwired/stimulus'
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ['star', 'input']
|
static targets = ['star', 'input', 'error']
|
||||||
static values = { rating: { type: Number, default: 0 } }
|
static values = { rating: { type: Number, default: 0 } }
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.#fill(this.ratingValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound to the form's submit event (this controller sits on the <form>
|
||||||
|
// itself, not just the star widget) — a plain `required` on the hidden
|
||||||
|
// rating input would never surface: browsers exclude type="hidden" from
|
||||||
|
// constraint validation entirely, so there'd be nothing to see or hear.
|
||||||
|
validate(event) {
|
||||||
|
if (this.ratingValue < 1) {
|
||||||
|
event.preventDefault()
|
||||||
|
this.errorTarget.hidden = false
|
||||||
|
this.starTargets[0]?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hover(event) {
|
hover(event) {
|
||||||
this.#fill(parseInt(event.currentTarget.dataset.value))
|
this.#fill(parseInt(event.currentTarget.dataset.value))
|
||||||
}
|
}
|
||||||
@@ -16,6 +32,7 @@ export default class extends Controller {
|
|||||||
const val = parseInt(event.currentTarget.dataset.value)
|
const val = parseInt(event.currentTarget.dataset.value)
|
||||||
this.ratingValue = val
|
this.ratingValue = val
|
||||||
this.inputTarget.value = val
|
this.inputTarget.value = val
|
||||||
|
this.errorTarget.hidden = true
|
||||||
|
|
||||||
this.starTargets.forEach(star => {
|
this.starTargets.forEach(star => {
|
||||||
star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val))
|
star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val))
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
// Jumps to a tab panel from an element outside the tabs' own markup — the
|
||||||
|
// review count and the "read more" link both sit elsewhere in the DOM, too
|
||||||
|
// far apart from the tabs for a plain data-action, hence the outlet.
|
||||||
|
export default class extends Controller {
|
||||||
|
static outlets = ['tabs']
|
||||||
|
static values = { panel: String }
|
||||||
|
|
||||||
|
activate(event) {
|
||||||
|
event?.preventDefault()
|
||||||
|
this.tabsOutlet.activate(this.panelValue)
|
||||||
|
this.tabsOutlet.element.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ export default class extends Controller {
|
|||||||
static targets = ['button', 'panel']
|
static targets = ['button', 'panel']
|
||||||
|
|
||||||
show(event) {
|
show(event) {
|
||||||
this.#activate(event.currentTarget.dataset.panel)
|
this.activate(event.currentTarget.dataset.panel)
|
||||||
}
|
}
|
||||||
|
|
||||||
#activate(panelId) {
|
activate(panelId) {
|
||||||
this.buttonTargets.forEach(btn => {
|
this.buttonTargets.forEach(btn => {
|
||||||
const active = btn.dataset.panel === panelId
|
const active = btn.dataset.panel === panelId
|
||||||
btn.classList.toggle('is-active', active)
|
btn.classList.toggle('is-active', active)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@extends('layouts.account')
|
||||||
|
|
||||||
|
@section('title', __('storefront.auth.enter_code'))
|
||||||
|
|
||||||
|
@section('account')
|
||||||
|
|
||||||
|
<div class="flex max-w-2xl flex-col gap-12">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||||
|
{{ __('storefront.auth.enter_code') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{ __('storefront.auth.code_sent_to') }} <strong class="break-all">{{ $email }}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<x-ui.status />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('account.email.verify') }}" class="flex flex-col gap-8">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<x-ui.field
|
||||||
|
:label="__('storefront.auth.code')"
|
||||||
|
for="account-email-code"
|
||||||
|
:required="true"
|
||||||
|
:error="$errors->first('code')"
|
||||||
|
>
|
||||||
|
<x-ui.input
|
||||||
|
id="account-email-code"
|
||||||
|
name="code"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
maxlength="6"
|
||||||
|
:required="true"
|
||||||
|
autofocus
|
||||||
|
class="text-2xl font-bold tracking-[0.5em]"
|
||||||
|
:aria-invalid="$errors->has('code') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('code') ? 'account-email-code-error' : null"
|
||||||
|
/>
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-ui.button type="submit">{{ __('storefront.account.email_confirm') }}</x-ui.button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-x-8 gap-y-4 text-sm">
|
||||||
|
<form method="POST" action="{{ route('account.email.resend') }}">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="cursor-pointer underline hover:no-underline">
|
||||||
|
{{ __('storefront.auth.resend_code') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a href="{{ route('account.email.edit') }}" class="underline hover:no-underline">
|
||||||
|
{{ __('storefront.auth.change_email') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@extends('layouts.account')
|
||||||
|
|
||||||
|
@section('title', __('storefront.account.email_change_heading'))
|
||||||
|
|
||||||
|
@section('account')
|
||||||
|
|
||||||
|
<div class="flex max-w-2xl flex-col gap-12">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||||
|
{{ __('storefront.account.email_change_heading') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{ __('storefront.account.email_current') }} <strong class="break-all">{{ $user->email }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('account.email.send') }}" class="flex flex-col gap-8">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<x-ui.field
|
||||||
|
:label="__('storefront.account.email_new')"
|
||||||
|
for="account-new-email"
|
||||||
|
:required="true"
|
||||||
|
:description="__('storefront.account.email_new_hint')"
|
||||||
|
:error="$errors->first('email')"
|
||||||
|
>
|
||||||
|
<x-ui.input
|
||||||
|
id="account-new-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
:value="old('email')"
|
||||||
|
:required="true"
|
||||||
|
autofocus
|
||||||
|
:aria-invalid="$errors->has('email') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('email') ? 'account-new-email-error' : 'account-new-email-description'"
|
||||||
|
/>
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-8">
|
||||||
|
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
|
||||||
|
|
||||||
|
<a href="{{ route('account') }}" class="text-sm underline hover:no-underline">
|
||||||
|
{{ __('storefront.account.delete_cancel') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
@extends('layouts.account')
|
||||||
|
|
||||||
|
@section('title', __('storefront.account.nav_orders'))
|
||||||
|
|
||||||
|
@section('account')
|
||||||
|
|
||||||
|
<div class="flex max-w-4xl flex-col gap-12">
|
||||||
|
|
||||||
|
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||||
|
{{ __('storefront.account.nav_orders') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
@if ($orders->isEmpty())
|
||||||
|
<div class="flex flex-col items-start gap-8">
|
||||||
|
<p>{{ __('storefront.orders.empty') }}</p>
|
||||||
|
<x-ui.button :href="route('products')" size="md">{{ __('storefront.orders.shop_now') }}</x-ui.button>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
{{-- A list, not a table, so each order can stack on mobile. The column
|
||||||
|
headings are visual only; each cell carries its own sr-only label. --}}
|
||||||
|
<div>
|
||||||
|
<div class="hidden grid-cols-[1fr_1fr_1.5fr_1fr_auto] gap-6 border-b border-black pb-3 text-sm uppercase font-display font-extrabold sm:grid" aria-hidden="true">
|
||||||
|
<span>{{ __('storefront.orders.date') }}</span>
|
||||||
|
<span>{{ __('storefront.orders.number') }}</span>
|
||||||
|
<span>{{ __('storefront.orders.status') }}</span>
|
||||||
|
<span>{{ __('storefront.orders.total') }}</span>
|
||||||
|
<span class="w-24"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
@foreach ($orders as $order)
|
||||||
|
<li class="grid grid-cols-2 gap-x-6 gap-y-2 border-b border-black py-5 sm:grid-cols-[1fr_1fr_1.5fr_1fr_auto] sm:items-center">
|
||||||
|
<span>
|
||||||
|
<span class="sr-only">{{ __('storefront.orders.date') }}:</span>
|
||||||
|
{{ $order->placed_at->format('d/m/Y') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-right font-bold sm:text-left">
|
||||||
|
<span class="sr-only">{{ __('storefront.orders.number') }}:</span>
|
||||||
|
#{{ $order->reference }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span class="sr-only">{{ __('storefront.orders.status') }}:</span>
|
||||||
|
<x-order-status :status="$order->status" />
|
||||||
|
</span>
|
||||||
|
<span class="text-right sm:text-left">
|
||||||
|
<span class="sr-only">{{ __('storefront.orders.total') }}:</span>
|
||||||
|
{{ $order->total?->formatted() }}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="{{ route('account.orders.show', ['orderId' => $order->id]) }}"
|
||||||
|
class="col-span-2 mt-2 inline-flex w-24 items-center gap-2 font-bold underline hover:no-underline sm:col-span-1 sm:mt-0"
|
||||||
|
aria-label="{{ __('storefront.orders.view_order', ['number' => $order->reference]) }}"
|
||||||
|
>
|
||||||
|
{{ __('storefront.orders.view') }}
|
||||||
|
<x-ui.icon name="arrow-right" :size="16" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-ui.pagination :paginator="$orders" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
@extends('layouts.account')
|
||||||
|
|
||||||
|
@section('title', __('storefront.orders.order_title', ['number' => $order->reference]))
|
||||||
|
|
||||||
|
@php
|
||||||
|
$productLines = $order->lines->where('type', '!=', 'shipping');
|
||||||
|
$shippingLine = $order->lines->firstWhere('type', 'shipping');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@section('account')
|
||||||
|
|
||||||
|
<div class="flex max-w-4xl flex-col gap-12">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<a href="{{ route('account.orders') }}" class="inline-flex items-center gap-2 self-start text-sm underline hover:no-underline">
|
||||||
|
<x-ui.icon name="arrow-left" :size="16" />
|
||||||
|
{{ __('storefront.orders.back') }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||||
|
{{ __('storefront.orders.order_title', ['number' => $order->reference]) }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Summary --}}
|
||||||
|
<dl class="grid grid-cols-1 gap-6 border-y border-black py-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.date') }}</dt>
|
||||||
|
<dd>{{ $order->placed_at->format('d/m/Y') }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.status') }}</dt>
|
||||||
|
<dd class="font-bold"><x-order-status :status="$order->status" /></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($paymentMethodName)
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.payment') }}</dt>
|
||||||
|
<dd>{{ $paymentMethodName }}</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($shippingLine)
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.shipping_method') }}</dt>
|
||||||
|
<dd>{{ $shippingLine->description }}</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@foreach ($shipments as $shipment)
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.tracking') }}</dt>
|
||||||
|
<dd class="break-all">{{ $shipment->tracking_reference }}</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{{-- Items + totals --}}
|
||||||
|
<section class="flex flex-col gap-6" aria-labelledby="order-items-heading">
|
||||||
|
<h2 id="order-items-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.orders.items') }}</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
@foreach ($productLines as $line)
|
||||||
|
<li class="flex gap-4 border-b border-black py-5 sm:gap-6">
|
||||||
|
<div class="size-18 shrink-0 bg-neutral-300 sm:size-24">
|
||||||
|
@if ($thumb = $line->purchasable?->getThumbnailImage())
|
||||||
|
<img src="{{ $thumb }}" alt="" aria-hidden="true" width="96" height="96" loading="lazy" class="size-full object-cover">
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
{{-- Linked only while the product still exists and is published;
|
||||||
|
otherwise the name stays plain text (the order keeps it). --}}
|
||||||
|
@php($lineProduct = $line->purchasable?->product)
|
||||||
|
<p class="font-bold">
|
||||||
|
@if ($lineProduct?->status === 'published')
|
||||||
|
<a href="{{ route('product.show', ['id' => $lineProduct->id]) }}" class="underline hover:no-underline">{{ $line->description }}</a>
|
||||||
|
@else
|
||||||
|
{{ $line->description }}
|
||||||
|
@endif
|
||||||
|
<span class="font-normal">× {{ $line->quantity }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if ($line->option)
|
||||||
|
<p class="text-sm text-neutral-500">{{ $line->option }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="shrink-0 font-bold">{{ $line->sub_total?->formatted() }}</p>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<dl class="ml-auto flex w-full max-w-sm flex-col gap-2">
|
||||||
|
<div class="flex justify-between gap-6">
|
||||||
|
<dt>{{ __('storefront.orders.subtotal') }}</dt>
|
||||||
|
<dd>{{ $order->sub_total?->formatted() }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($order->discount_total?->value > 0)
|
||||||
|
<div class="flex justify-between gap-6">
|
||||||
|
<dt>{{ __('storefront.orders.discount') }}</dt>
|
||||||
|
<dd>−{{ $order->discount_total->formatted() }}</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-6">
|
||||||
|
<dt>{{ __('storefront.orders.shipping') }}</dt>
|
||||||
|
<dd>{{ $order->shipping_total?->formatted() }}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($order->tax_total?->value > 0)
|
||||||
|
<div class="flex justify-between gap-6 text-sm text-neutral-500">
|
||||||
|
<dt>{{ __('storefront.orders.tax') }}</dt>
|
||||||
|
<dd>{{ $order->tax_total->formatted() }}</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-6 border-t border-black pt-3 font-display text-h4 font-extrabold">
|
||||||
|
<dt>{{ __('storefront.orders.total') }}</dt>
|
||||||
|
<dd>{{ $order->total?->formatted() }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Addresses --}}
|
||||||
|
<div class="grid grid-cols-1 gap-12 sm:grid-cols-2">
|
||||||
|
@foreach ([
|
||||||
|
'shipping_to' => $order->shippingAddress,
|
||||||
|
'billing' => $order->billingAddress,
|
||||||
|
] as $heading => $address)
|
||||||
|
@if ($address)
|
||||||
|
<section class="flex flex-col gap-4" aria-labelledby="order-{{ $heading }}-heading">
|
||||||
|
<h2 id="order-{{ $heading }}-heading" class="font-display text-h4 font-extrabold">{{ __("storefront.orders.{$heading}") }}</h2>
|
||||||
|
|
||||||
|
<address class="flex flex-col not-italic">
|
||||||
|
<span>{{ trim($address->first_name.' '.$address->last_name) }}</span>
|
||||||
|
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
|
||||||
|
@if ($address->tax_identifier)<span>{{ __('storefront.account.tax_identifier') }}: {{ $address->tax_identifier }}</span>@endif
|
||||||
|
<span>{{ $address->line_one }}</span>
|
||||||
|
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
|
||||||
|
<span>{{ trim($address->postcode.' '.$address->city) }}</span>
|
||||||
|
@if ($address->state)
|
||||||
|
<span>{{ Lang::has("core::states.{$address->state}") ? __("core::states.{$address->state}") : $address->state }}</span>
|
||||||
|
@endif
|
||||||
|
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
|
||||||
|
</address>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
@extends('layouts.account')
|
||||||
|
|
||||||
|
@section('title', __('storefront.account.nav_profile'))
|
||||||
|
|
||||||
|
@php
|
||||||
|
$wantsInvoice = (bool) old('invoice', filled($customer?->company_name) || filled($customer?->tax_identifier));
|
||||||
|
|
||||||
|
$regionOptions = $regions->map(fn ($region) => [
|
||||||
|
'value' => $region->name,
|
||||||
|
'label' => Lang::has("core::states.{$region->name}") ? __("core::states.{$region->name}") : $region->name,
|
||||||
|
])->all();
|
||||||
|
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@section('account')
|
||||||
|
|
||||||
|
<div class="flex max-w-2xl flex-col gap-16">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||||
|
{{ __('storefront.account.nav_profile') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<x-ui.status />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Email: the login itself, so it's changed through its own verified flow --}}
|
||||||
|
<section class="flex flex-col gap-6" aria-labelledby="account-email-heading">
|
||||||
|
<h2 id="account-email-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.email_heading') }}</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-x-8 gap-y-2 border-b border-black py-2">
|
||||||
|
<span class="break-all">{{ $user->email }}</span>
|
||||||
|
<a href="{{ route('account.email.edit') }}" class="text-sm underline hover:no-underline">
|
||||||
|
{{ __('storefront.account.email_change') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('account.update') }}" class="flex flex-col gap-16">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
|
||||||
|
{{-- Details --}}
|
||||||
|
<section class="flex flex-col gap-8" aria-labelledby="account-details-heading">
|
||||||
|
<h2 id="account-details-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.details_heading') }}</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||||
|
<x-ui.field :label="__('storefront.account.first_name')" for="account-first-name" :error="$errors->first('first_name')">
|
||||||
|
<x-ui.input id="account-first-name" name="first_name" autocomplete="given-name"
|
||||||
|
:value="old('first_name', $customer?->first_name)"
|
||||||
|
:aria-invalid="$errors->has('first_name') ? 'true' : null" :aria-describedby="$errors->has('first_name') ? 'account-first-name-error' : null" />
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
<x-ui.field :label="__('storefront.account.last_name')" for="account-last-name" :error="$errors->first('last_name')">
|
||||||
|
<x-ui.input id="account-last-name" name="last_name" autocomplete="family-name"
|
||||||
|
:value="old('last_name', $customer?->last_name)"
|
||||||
|
:aria-invalid="$errors->has('last_name') ? 'true' : null" :aria-describedby="$errors->has('last_name') ? 'account-last-name-error' : null" />
|
||||||
|
</x-ui.field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Invoice details, revealed by the checkbox (CSS :has(), no JS). Not
|
||||||
|
`required` in HTML: a hidden required field would block submit, so
|
||||||
|
the server requires them only when the box is ticked. --}}
|
||||||
|
<div class="group/invoice flex flex-col gap-8">
|
||||||
|
<input type="hidden" name="invoice" value="0">
|
||||||
|
<x-ui.checkbox
|
||||||
|
id="account-invoice"
|
||||||
|
name="invoice"
|
||||||
|
:checked="$wantsInvoice"
|
||||||
|
:label="__('storefront.account.invoice')"
|
||||||
|
aria-controls="account-invoice-fields"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div id="account-invoice-fields" class="hidden grid-cols-1 gap-8 sm:grid-cols-2 group-has-[#account-invoice:checked]/invoice:grid">
|
||||||
|
<x-ui.field :label="__('storefront.account.company_name')" for="account-company" :error="$errors->first('company_name')">
|
||||||
|
<x-ui.input id="account-company" name="company_name" autocomplete="organization"
|
||||||
|
:value="old('company_name', $customer?->company_name)"
|
||||||
|
:aria-invalid="$errors->has('company_name') ? 'true' : null" :aria-describedby="$errors->has('company_name') ? 'account-company-error' : null" />
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
<x-ui.field :label="__('storefront.account.tax_identifier')" for="account-tax-id" :error="$errors->first('tax_identifier')">
|
||||||
|
<x-ui.input id="account-tax-id" name="tax_identifier" inputmode="numeric" maxlength="9" pattern="[0-9]{9}"
|
||||||
|
:value="old('tax_identifier', $customer?->tax_identifier)"
|
||||||
|
:aria-invalid="$errors->has('tax_identifier') ? 'true' : null" :aria-describedby="$errors->has('tax_identifier') ? 'account-tax-id-error' : null" />
|
||||||
|
</x-ui.field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Address: one, used as both shipping and billing default --}}
|
||||||
|
<section class="flex flex-col gap-8" aria-labelledby="account-address-heading">
|
||||||
|
<h2 id="account-address-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.address_heading') }}</h2>
|
||||||
|
|
||||||
|
<x-ui.field :label="__('storefront.account.line_one')" for="account-line-one" :error="$errors->first('line_one')">
|
||||||
|
<x-ui.input id="account-line-one" name="line_one" autocomplete="address-line1"
|
||||||
|
:value="old('line_one', $address?->line_one)"
|
||||||
|
:aria-invalid="$errors->has('line_one') ? 'true' : null" :aria-describedby="$errors->has('line_one') ? 'account-line-one-error' : null" />
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||||
|
<x-ui.field :label="__('storefront.account.city')" for="account-city" :error="$errors->first('city')">
|
||||||
|
<x-ui.input id="account-city" name="city" autocomplete="address-level2"
|
||||||
|
:value="old('city', $address?->city)"
|
||||||
|
:aria-invalid="$errors->has('city') ? 'true' : null" :aria-describedby="$errors->has('city') ? 'account-city-error' : null" />
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
<x-ui.field :label="__('storefront.account.postcode')" for="account-postcode" :error="$errors->first('postcode')">
|
||||||
|
<x-ui.input id="account-postcode" name="postcode" autocomplete="postal-code" inputmode="numeric"
|
||||||
|
:value="old('postcode', $address?->postcode)"
|
||||||
|
:aria-invalid="$errors->has('postcode') ? 'true' : null" :aria-describedby="$errors->has('postcode') ? 'account-postcode-error' : null" />
|
||||||
|
</x-ui.field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||||
|
<x-ui.field :label="__('storefront.account.state')" for="account-state" :error="$errors->first('state')">
|
||||||
|
<x-ui.select
|
||||||
|
id="account-state"
|
||||||
|
name="state"
|
||||||
|
:block="true"
|
||||||
|
:options="$regionOptions"
|
||||||
|
:value="old('state', $address?->state)"
|
||||||
|
:placeholder="__('storefront.account.state_placeholder')"
|
||||||
|
:aria-invalid="$errors->has('state') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('state') ? 'account-state-error' : null"
|
||||||
|
/>
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
<x-ui.field :label="__('storefront.account.phone')" for="account-phone" :error="$errors->first('contact_phone')">
|
||||||
|
<x-ui.input id="account-phone" name="contact_phone" type="tel" autocomplete="tel"
|
||||||
|
:value="old('contact_phone', $address?->contact_phone)"
|
||||||
|
:aria-invalid="$errors->has('contact_phone') ? 'true' : null" :aria-describedby="$errors->has('contact_phone') ? 'account-phone-error' : null" />
|
||||||
|
</x-ui.field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Standing opt-in for abandoned-cart reminders (explicit, off by
|
||||||
|
default); checkout starts from it and can change it again. --}}
|
||||||
|
<section class="flex flex-col gap-8" aria-labelledby="account-emails-heading">
|
||||||
|
<h2 id="account-emails-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.emails_heading') }}</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input type="hidden" name="recovery_consent" value="0">
|
||||||
|
<x-ui.checkbox
|
||||||
|
id="account-recovery-consent"
|
||||||
|
name="recovery_consent"
|
||||||
|
:checked="(bool) old('recovery_consent', data_get($customer, 'meta.recovery_consent'))"
|
||||||
|
:label="__('storefront.account.recovery_consent')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-ui.button type="submit">{{ __('storefront.account.save') }}</x-ui.button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{-- Delete account --}}
|
||||||
|
<section class="flex flex-col gap-6 border-t border-black pt-12" aria-labelledby="account-delete-heading">
|
||||||
|
<h2 id="account-delete-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.delete_heading') }}</h2>
|
||||||
|
|
||||||
|
<p>{{ __('storefront.account.delete_text') }}</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-ui.button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
popovertarget="account-delete-dialog"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
>{{ __('storefront.account.delete') }}</x-ui.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="account-delete-dialog"
|
||||||
|
popover
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="account-delete-dialog-heading"
|
||||||
|
class="m-auto w-[calc(100%-2rem)] max-w-lg border border-black bg-neutral-200 p-8 backdrop:bg-black/50"
|
||||||
|
>
|
||||||
|
<h3 id="account-delete-dialog-heading" class="mb-4 font-display text-h4 font-extrabold">
|
||||||
|
{{ __('storefront.account.delete_confirm_heading') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="mb-8">{{ __('storefront.account.delete_confirm_text') }}</p>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('account.destroy') }}" class="flex flex-wrap gap-4">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
|
||||||
|
<x-ui.button type="submit" size="md">{{ __('storefront.account.delete_confirm') }}</x-ui.button>
|
||||||
|
|
||||||
|
<x-ui.button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
popovertarget="account-delete-dialog"
|
||||||
|
popovertargetaction="hide"
|
||||||
|
>{{ __('storefront.account.delete_cancel') }}</x-ui.button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@extends('layouts.account')
|
||||||
|
|
||||||
|
@section('title', __('storefront.account.nav_wishlist'))
|
||||||
|
|
||||||
|
@section('account')
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-12">
|
||||||
|
|
||||||
|
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||||
|
{{ __('storefront.account.nav_wishlist') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
@include('wishlist.list')
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', __('storefront.auth.enter_code'))
|
||||||
|
|
||||||
|
@push('seo')
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
|
||||||
|
|
||||||
|
<h1 class="mb-6 font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||||
|
{{ __('storefront.auth.enter_code') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mb-12">
|
||||||
|
{{ __('storefront.auth.code_sent_to') }} <strong class="break-all">{{ $email }}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<x-ui.status class="mb-8" />
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('login.verify') }}" class="flex flex-col gap-8">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<x-ui.field
|
||||||
|
:label="__('storefront.auth.code')"
|
||||||
|
for="login-code"
|
||||||
|
:required="true"
|
||||||
|
:error="$errors->first('code')"
|
||||||
|
>
|
||||||
|
<x-ui.input
|
||||||
|
id="login-code"
|
||||||
|
name="code"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
maxlength="6"
|
||||||
|
:required="true"
|
||||||
|
autofocus
|
||||||
|
class="text-2xl font-bold tracking-[0.5em]"
|
||||||
|
:aria-invalid="$errors->has('code') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('code') ? 'login-code-error' : null"
|
||||||
|
/>
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-ui.button type="submit">{{ __('storefront.auth.login') }}</x-ui.button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-12 flex flex-wrap items-center gap-x-8 gap-y-4 text-sm">
|
||||||
|
<form method="POST" action="{{ route('login.resend') }}">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="cursor-pointer underline hover:no-underline">
|
||||||
|
{{ __('storefront.auth.resend_code') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a href="{{ route('login') }}" class="underline hover:no-underline">
|
||||||
|
{{ __('storefront.auth.change_email') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', __('storefront.auth.login'))
|
||||||
|
|
||||||
|
@push('seo')
|
||||||
|
<meta name="robots" content="noindex, follow">
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
{{-- Only on pages with a captcha, not in the global layout. --}}
|
||||||
|
@push('scripts')
|
||||||
|
<script src="https://js.hcaptcha.com/1/api.js?hl={{ app()->getLocale() }}" async defer></script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
|
||||||
|
|
||||||
|
<h1 class="mb-6 font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||||
|
{{ __('storefront.auth.login') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{{-- Unescaped: the label holds a <br /> (edited in Filament › Language Lines, staff only). --}}
|
||||||
|
<p class="mb-12">{!! __('storefront.auth.login_intro') !!}</p>
|
||||||
|
|
||||||
|
{{-- e.g. "account deletion scheduled", after AccountController::destroy() --}}
|
||||||
|
<x-ui.status class="mb-12" />
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('login.send') }}" class="flex flex-col gap-8">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<x-ui.field
|
||||||
|
:label="__('storefront.auth.email')"
|
||||||
|
for="login-email"
|
||||||
|
:required="true"
|
||||||
|
:error="$errors->first('email')"
|
||||||
|
>
|
||||||
|
<x-ui.input
|
||||||
|
id="login-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
:value="old('email')"
|
||||||
|
:required="true"
|
||||||
|
autofocus
|
||||||
|
:aria-invalid="$errors->has('email') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('email') ? 'login-email-error' : null"
|
||||||
|
/>
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
{{-- Shown to everyone. Recording acceptance for new accounts is a
|
||||||
|
boboko-core task (UserOtpService). Unescaped: the label holds the
|
||||||
|
two links (Language Lines, staff only). --}}
|
||||||
|
<p class="text-sm text-neutral-500 [&_a]:underline [&_a:hover]:no-underline">
|
||||||
|
{!! __('storefront.auth.terms_notice', [
|
||||||
|
'terms' => route('legal.terms'),
|
||||||
|
'privacy' => route('legal.privacy'),
|
||||||
|
]) !!}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- hCaptcha checkbox, verified by App\Rules\HCaptcha. The widget adds
|
||||||
|
the `h-captcha-response` field itself. Fixed height reserves the
|
||||||
|
iframe's space so the button doesn't jump when it loads. --}}
|
||||||
|
<x-ui.field for="login-captcha" :error="$errors->first('h-captcha-response')">
|
||||||
|
<div
|
||||||
|
id="login-captcha"
|
||||||
|
class="h-captcha min-h-[78px]"
|
||||||
|
data-sitekey="{{ config('services.hcaptcha.sitekey') }}"
|
||||||
|
@if ($errors->has('h-captcha-response')) aria-describedby="login-captcha-error" @endif
|
||||||
|
></div>
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@endsection
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
@endpush
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
|
||||||
|
|
||||||
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
||||||
<h1 class="font-medium text-h2">{{ $collection['name'] }}</h1>
|
<h1 class="font-medium text-h2">{{ $collection['name'] }}</h1>
|
||||||
|
|||||||
@@ -41,11 +41,42 @@
|
|||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
{{-- Cart --}}
|
{{-- Wishlist — the account page, or the cookie-based guest page --}}
|
||||||
<a href="{{ url('/'.app()->getLocale().'/cart') }}" class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity" aria-label="Cart">
|
<a
|
||||||
<x-ui.icon name="bag" :size="40" />
|
href="{{ auth()->check() ? route('account.wishlist') : route('wishlist') }}"
|
||||||
|
class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
|
||||||
|
aria-label="{{ __('storefront.account.nav_wishlist') }}"
|
||||||
|
@if (request()->routeIs('account.wishlist', 'wishlist')) aria-current="page" @endif
|
||||||
|
>
|
||||||
|
<x-ui.icon name="heart" :size="40" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{{-- Account — the account page, or login for guests --}}
|
||||||
|
<a
|
||||||
|
href="{{ auth()->check() ? route('account') : route('login') }}"
|
||||||
|
class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
|
||||||
|
aria-label="{{ auth()->check() ? __('storefront.nav.account') : __('storefront.auth.login') }}"
|
||||||
|
>
|
||||||
|
<x-ui.icon name="user" :size="40" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{-- Cart — opens the checkout module's drawer, no separate cart page --}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
|
||||||
|
aria-label="Cart"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
data-controller="cart-count"
|
||||||
|
data-action="cart-count#open"
|
||||||
|
>
|
||||||
|
<x-ui.icon name="bag" :size="40" />
|
||||||
|
<span
|
||||||
|
data-cart-count-target="badge"
|
||||||
|
class="absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full bg-black text-neutral-200 text-xs font-bold flex items-center justify-center"
|
||||||
|
hidden
|
||||||
|
>0</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{{-- Search --}}
|
{{-- Search --}}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
<section {{ $attributes }}>
|
<section {{ $attributes }}>
|
||||||
<div class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-2 border border-black">
|
<div class="grid grid-cols-1 lg:grid-cols-2">
|
||||||
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16">
|
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16 items-center">
|
||||||
<h2
|
<h2
|
||||||
class="text-h2 leading-tight"
|
class="text-h1 leading-[1.3] max-w-xl text-center"
|
||||||
aria-label="Γράψου στο newsletter μας"
|
aria-label="Γράψου στο newsletter μας"
|
||||||
data-controller="appear"
|
data-controller="appear"
|
||||||
data-appear-visible-class="is-visible"
|
data-appear-visible-class="is-visible"
|
||||||
@@ -17,13 +17,13 @@ class="text-h2 leading-tight"
|
|||||||
μας
|
μας
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="max-w-md text-neutral-600">
|
<p class="max-w-xl text-neutral-600 text-center">
|
||||||
Γίνε μέλος της λίστας μας και κέρδισε <span class="font-bold">5% έκπτωση</span> στην πρώτη σου παραγγελία, μαζί με αποκλειστικές προσφορές και νέα.
|
Γίνε μέλος της λίστας μας και κέρδισε <span class="font-bold">5% έκπτωση</span> στην πρώτη σου παραγγελία, μαζί με αποκλειστικές προσφορές και νέα.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST" action="#" class="flex flex-col gap-6 max-w-md">
|
<form method="POST" action="#" class="flex flex-col gap-6 max-w-xl items-center">
|
||||||
@csrf
|
@csrf
|
||||||
<div class="relative">
|
<div class="relative max-w-xs w-full">
|
||||||
<x-ui.input
|
<x-ui.input
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -44,7 +44,7 @@ class="absolute right-0 bottom-2 cursor-pointer"
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-ui.checkbox name="gdpr_consent" :required="true" id="newsletter_split_gdpr_consent" class="after:mix-blend-multiply">
|
<x-ui.checkbox name="gdpr_consent" :required="true" id="newsletter_split_gdpr_consent" class="after:mix-blend-multiply">
|
||||||
<span class="text-sm text-left">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
<span class="text-sm">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
||||||
</x-ui.checkbox>
|
</x-ui.checkbox>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{--
|
||||||
|
An order status as text, e.g. "Σε επεξεργασία". Translated through
|
||||||
|
storefront.order_status.{status}, falling back to Lunar's own (English)
|
||||||
|
label from config('lunar.orders.statuses') when that key doesn't exist.
|
||||||
|
--}}
|
||||||
|
@props(['status'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$key = "storefront.order_status.{$status}";
|
||||||
|
$label = Lang::has($key) ? __($key) : config("lunar.orders.statuses.{$status}.label", $status);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<span {{ $attributes }}>{{ $label }}</span>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{{--
|
||||||
|
<x-product-custom-fields :fields="$product['custom_fields']" />
|
||||||
|
|
||||||
|
The product's custom fields (boboko-core's Product::$custom_fields), rendered
|
||||||
|
inside the add-to-cart <form> so their values travel with it as
|
||||||
|
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
|
||||||
|
|
||||||
|
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
|
||||||
|
and only the resulting File row's id (boboko-core's Modules\Core\File\
|
||||||
|
Models\File) is submitted with the form — the file input itself has no
|
||||||
|
name. It still carries `required`, so the browser's own validation blocks
|
||||||
|
add-to-cart until a photo is picked, and the controller marks it invalid
|
||||||
|
(setCustomValidity) while the upload is in flight.
|
||||||
|
--}}
|
||||||
|
@props(['fields' => []])
|
||||||
|
|
||||||
|
@use('App\Http\Controllers\CustomFieldUploadController')
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
@foreach ($fields as $field)
|
||||||
|
@php
|
||||||
|
$id = 'custom-field-'.$field['key'];
|
||||||
|
$name = 'custom_fields['.$field['key'].']';
|
||||||
|
$required = (bool) ($field['required'] ?? false);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@switch($field['type'])
|
||||||
|
@case('textarea')
|
||||||
|
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
|
||||||
|
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" />
|
||||||
|
</x-ui.field>
|
||||||
|
@break
|
||||||
|
|
||||||
|
@case('file')
|
||||||
|
<x-ui.field
|
||||||
|
:label="$field['label']"
|
||||||
|
:for="$id"
|
||||||
|
:required="$required"
|
||||||
|
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
|
||||||
|
data-controller="custom-field-upload"
|
||||||
|
data-custom-field-upload-url-value="{{ route('custom-field-upload.store') }}"
|
||||||
|
data-custom-field-upload-label-value="{{ $field['label'] }}"
|
||||||
|
data-custom-field-upload-uploading-message-value="{{ __('storefront.product.custom_field_uploading') }}"
|
||||||
|
data-custom-field-upload-failed-message-value="{{ __('storefront.product.custom_field_upload_failed') }}"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="{{ $name }}" data-custom-field-upload-target="reference">
|
||||||
|
|
||||||
|
<x-ui.file-input
|
||||||
|
:id="$id"
|
||||||
|
:accept="CustomFieldUploadController::accept()"
|
||||||
|
:required="$required"
|
||||||
|
aria-describedby="{{ $id }}-description {{ $id }}-error"
|
||||||
|
data-custom-field-upload-target="file"
|
||||||
|
data-action="change->custom-field-upload#upload"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
width="96"
|
||||||
|
height="96"
|
||||||
|
hidden
|
||||||
|
class="w-24 h-24 object-cover border border-black"
|
||||||
|
data-custom-field-upload-target="preview"
|
||||||
|
data-action="error->custom-field-upload#hidePreview"
|
||||||
|
>
|
||||||
|
|
||||||
|
<p id="{{ $id }}-error" class="text-sm text-red-600" role="alert" hidden data-custom-field-upload-target="error"></p>
|
||||||
|
</x-ui.field>
|
||||||
|
@break
|
||||||
|
|
||||||
|
@default
|
||||||
|
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
|
||||||
|
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" />
|
||||||
|
</x-ui.field>
|
||||||
|
@endswitch
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
:price="$product['price'] ?? null"
|
:price="$product['price'] ?? null"
|
||||||
:image="$product['image'] ?? null"
|
:image="$product['image'] ?? null"
|
||||||
:href="$product['href'] ?? '#'"
|
:href="$product['href'] ?? '#'"
|
||||||
|
:variant-id="$product['variantId'] ?? null"
|
||||||
|
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||||
|
:sold-out="$product['soldOut'] ?? false"
|
||||||
/>
|
/>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
@props(['review'])
|
@props(['review'])
|
||||||
|
|
||||||
<article class="flex flex-col gap-3 py-6 border-b border-black last:border-0">
|
<article class="flex flex-col gap-3 py-6">
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
<x-reviews-stars :rating="$review['rating']" :size="23" />
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<x-reviews-stars :rating="$review['rating']" :size="18" />
|
<div class="flex items-end gap-4">
|
||||||
<span class="font-bold">
|
<span class="text-2xl font-bold leading-none">
|
||||||
{{ $review['name'] ?? 'Ανώνυμος' }}
|
{{ $review['name'] ?? 'Ανώνυμος' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<time class="text-sm text-neutral-500 shrink-0 leading-none pb-0.5">{{ $review['date'] }}</time>
|
||||||
<time class="text-sm text-neutral-500 shrink-0">{{ $review['date'] }}</time>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="leading-relaxed">{{ $review['text'] }}</p>
|
<p class="leading-relaxed">{{ $review['text'] }}</p>
|
||||||
|
|
||||||
@if (!empty($review['image']))
|
@if (!empty($review['image']))
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
|
<a href="{{ $review['image'] }}" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
src="{{ $review['image'] }}"
|
src="{{ $review['image'] }}"
|
||||||
alt="Φωτογραφία από τον αγοραστή"
|
alt="Φωτογραφία από τον αγοραστή"
|
||||||
class="w-24 h-24 object-cover border border-black"
|
class="w-24 h-24 object-cover border border-black"
|
||||||
>
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (!empty($review['reply']))
|
||||||
|
<div class="mt-2 pl-4 border-l-2 border-neutral-300 flex flex-col gap-1">
|
||||||
|
<p class="text-sm font-bold">
|
||||||
|
{{ __('storefront.review.reply') }}
|
||||||
|
@if (!empty($review['replyDate']))
|
||||||
|
<span class="font-normal text-neutral-500">— {{ $review['replyDate'] }}</span>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
<p class="leading-relaxed">{{ $review['reply'] }}</p>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
@props(['product'])
|
@props(['product'])
|
||||||
|
|
||||||
|
@php $oldRating = (int) old('rating', 0); @endphp
|
||||||
|
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="#"
|
action="{{ route('product.reviews.store', ['locale' => app()->getLocale(), 'product' => $product['id']]) }}"
|
||||||
class="flex flex-col gap-8 mt-10"
|
class="flex flex-col gap-8 mt-10"
|
||||||
|
data-controller="star-rating"
|
||||||
|
data-star-rating-rating-value="{{ $oldRating }}"
|
||||||
|
data-action="submit->star-rating#validate"
|
||||||
>
|
>
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
{{-- Rating --}}
|
{{-- Rating --}}
|
||||||
<x-ui.field :label="__('storefront.review.rating')" for="rating" :required="true">
|
<x-ui.field :label="__('storefront.review.rating')" for="rating" :required="true" :error="$errors->first('rating')">
|
||||||
<div
|
<div
|
||||||
id="rating"
|
id="rating"
|
||||||
data-controller="star-rating"
|
|
||||||
class="flex items-center gap-1.5 text-brand"
|
class="flex items-center gap-1.5 text-brand"
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="{{ __('storefront.review.rating') }}"
|
aria-label="{{ __('storefront.review.rating') }}"
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
|
aria-describedby="rating-client-error"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="rating" value="0" data-star-rating-target="input">
|
<input type="hidden" name="rating" value="{{ $oldRating }}" data-star-rating-target="input">
|
||||||
@for($i = 1; $i <= 5; $i++)
|
@for($i = 1; $i <= 5; $i++)
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -25,7 +30,7 @@ class="flex items-center gap-1.5 text-brand"
|
|||||||
data-value="{{ $i }}"
|
data-value="{{ $i }}"
|
||||||
data-action="click->star-rating#select mouseenter->star-rating#hover mouseleave->star-rating#leave"
|
data-action="click->star-rating#select mouseenter->star-rating#hover mouseleave->star-rating#leave"
|
||||||
aria-label="{{ trans_choice('storefront.review.stars_count', $i, ['count' => $i]) }}"
|
aria-label="{{ trans_choice('storefront.review.stars_count', $i, ['count' => $i]) }}"
|
||||||
aria-pressed="false"
|
aria-pressed="{{ $i === $oldRating ? 'true' : 'false' }}"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -41,24 +46,21 @@ class="w-6 h-6"
|
|||||||
</button>
|
</button>
|
||||||
@endfor
|
@endfor
|
||||||
</div>
|
</div>
|
||||||
|
<p id="rating-client-error" data-star-rating-target="error" class="text-sm text-red-600" role="alert" hidden>{{ __('storefront.review.rating_required') }}</p>
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
|
|
||||||
<x-ui.field :label="__('storefront.review.write_label')" for="review-content" :required="true">
|
<x-ui.field :label="__('storefront.review.write_label')" for="review-content" :required="true" :error="$errors->first('content')">
|
||||||
<x-ui.textarea id="review-content" name="content" :required="true" />
|
<x-ui.textarea id="review-content" name="content" :required="true" :rows="6">{{ old('content') }}</x-ui.textarea>
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
|
|
||||||
<x-ui.field :label="__('storefront.review.name')" for="review-name" :labelDescription="__('storefront.review.name_optional')">
|
<x-ui.field :label="__('storefront.review.name')" for="review-name" :labelDescription="__('storefront.review.name_optional')" :error="$errors->first('name')">
|
||||||
<x-ui.input id="review-name" name="name" autocomplete="name" />
|
<x-ui.input id="review-name" name="name" autocomplete="name" :value="old('name')" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
|
|
||||||
<x-ui.field :label="__('storefront.review.email')" for="review-email" :required="true" :labelDescription="__('storefront.review.email_not_published')">
|
<x-ui.field :label="__('storefront.review.email')" for="review-email" :required="true" :labelDescription="__('storefront.review.email_not_published')" :error="$errors->first('email')">
|
||||||
<x-ui.input id="review-email" name="email" type="email" :required="true" autocomplete="email" />
|
<x-ui.input id="review-email" name="email" type="email" :required="true" autocomplete="email" :value="old('email')" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
|
|
||||||
<x-ui.checkbox id="review-save-info" name="save_info" >
|
|
||||||
<span class="text-sm">{{ __('storefront.review.save_info') }}</span>
|
|
||||||
</x-ui.checkbox>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-ui.button type="submit">{{ __('storefront.review.submit') }}</x-ui.button>
|
<x-ui.button type="submit">{{ __('storefront.review.submit') }}</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
'count' => 0,
|
'count' => 0,
|
||||||
'showCount' => false,
|
'showCount' => false,
|
||||||
'size' => 24,
|
'size' => 24,
|
||||||
|
'linkable' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
<div class="flex items-center gap-3" {{ $attributes }}>
|
<div class="flex items-center gap-3" {{ $attributes }}>
|
||||||
@@ -28,9 +29,17 @@ class="flex items-center gap-1.5 text-brand"
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($showCount && $count > 0)
|
@if ($showCount && $count > 0)
|
||||||
<span class="text-sm text-neutral-500">
|
@php $countText = '(' . trans_choice('storefront.customer_reviews', $count, ['count' => $count]) . ')'; @endphp
|
||||||
({{ trans_choice('storefront.customer_reviews', $count, ['count' => $count]) }})
|
@if ($linkable)
|
||||||
</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="click->tab-link#activate"
|
||||||
|
class="text-sm text-neutral-500 hover:underline cursor-pointer"
|
||||||
|
aria-controls="tab-panel-reviews"
|
||||||
|
>{{ $countText }}</button>
|
||||||
|
@else
|
||||||
|
<span class="text-sm text-neutral-500">{{ $countText }}</span>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
'href' => null,
|
'href' => null,
|
||||||
'type' => 'button',
|
'type' => 'button',
|
||||||
'size' => 'lg',
|
'size' => 'lg',
|
||||||
|
'variant' => 'primary',
|
||||||
'position' => 'relative',
|
'position' => 'relative',
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -15,6 +16,16 @@
|
|||||||
default => 'py-5 px-[46px] text-[19px]',
|
default => 'py-5 px-[46px] text-[19px]',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 'primary' is the CTA look (offset-shadow via .btn-primary's ::before/
|
||||||
|
// ::after, italic, uppercase). 'secondary' is a plain bordered toggle —
|
||||||
|
// no shadow layers, fills solid on hover/aria-pressed=true instead, used
|
||||||
|
// for option pickers (see x-ui.option-buttons) and anywhere else a
|
||||||
|
// secondary/toggle action shouldn't compete visually with the CTA.
|
||||||
|
$variantClasses = match($variant) {
|
||||||
|
'secondary' => 'font-semibold hover:bg-black hover:text-neutral-200 aria-pressed:bg-black aria-pressed:text-neutral-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-black',
|
||||||
|
default => 'btn-primary font-bold italic uppercase',
|
||||||
|
};
|
||||||
|
|
||||||
// $position defaults to 'relative' (needed so the ::before/::after layers
|
// $position defaults to 'relative' (needed so the ::before/::after layers
|
||||||
// in .btn-primary position against the button itself), but callers that
|
// in .btn-primary position against the button itself), but callers that
|
||||||
// need to place the button absolutely (e.g. a hover-reveal CTA over a
|
// need to place the button absolutely (e.g. a hover-reveal CTA over a
|
||||||
@@ -22,7 +33,7 @@
|
|||||||
// "absolute" via the class prop — Tailwind's generated stylesheet always
|
// "absolute" via the class prop — Tailwind's generated stylesheet always
|
||||||
// orders the "relative" utility after "absolute", so on a class clash
|
// orders the "relative" utility after "absolute", so on a class clash
|
||||||
// "relative" silently wins and the button never actually gets positioned.
|
// "relative" silently wins and the button never actually gets positioned.
|
||||||
$class = 'btn-primary '.$position.' isolate inline-flex items-center justify-center border border-black font-display font-bold italic text-black cursor-pointer no-underline uppercase ' . $sizeClasses;
|
$class = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline disabled:cursor-not-allowed disabled:opacity-50 '.$variantClasses.' '.$sizeClasses);
|
||||||
|
|
||||||
$attrs = $href
|
$attrs = $href
|
||||||
? $attributes->merge(['href' => $href, 'class' => $class])
|
? $attributes->merge(['href' => $href, 'class' => $class])
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
{{-- $variants: array of Modules\Core\Catalog\Services\ProductIndexer's mapVariant()
|
{{-- $values: one product option's de-duplicated, ordered values, as built by
|
||||||
shape (id, options: [{option, value, meta}], ...) — plain arrays, not Eloquent
|
ProductController::buildOptionPicker() — [{id, label, hex}]. --}}
|
||||||
models, since this is fed from Modules\Core\Catalog\Services\ProductService. --}}
|
@props(['values', 'option' => null, 'optionHandle' => null])
|
||||||
@props(['variants', 'option' => null])
|
|
||||||
|
|
||||||
<div {{ $attributes }}>
|
<div {{ $attributes }}>
|
||||||
@if($option)
|
@if($option)
|
||||||
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
|
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
|
||||||
{{ $option }}: <span data-product-form-target="colorName" class="font-normal normal-case"></span>
|
{{ $option }}: <span data-product-form-target="colorName" data-option="{{ $optionHandle }}" class="font-normal normal-case"></span>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -15,22 +14,17 @@ class="flex flex-wrap gap-2"
|
|||||||
role="group"
|
role="group"
|
||||||
aria-label="{{ $option ?? 'Color' }}"
|
aria-label="{{ $option ?? 'Color' }}"
|
||||||
>
|
>
|
||||||
@foreach($variants as $variant)
|
@foreach($values as $value)
|
||||||
@php
|
|
||||||
$value = $variant['options'][0] ?? null;
|
|
||||||
$label = $value['value'] ?? '';
|
|
||||||
$bg = $value['meta']['hex'] ?? '#cccccc';
|
|
||||||
@endphp
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="color-swatch"
|
class="color-swatch"
|
||||||
data-product-form-target="swatch"
|
data-product-form-target="swatch"
|
||||||
data-action="click->product-form#selectVariant"
|
data-action="click->product-form#selectVariant"
|
||||||
data-variant-id="{{ $variant['id'] }}"
|
data-option="{{ $optionHandle }}"
|
||||||
style="background-color: {{ $bg }};"
|
data-value-id="{{ $value['id'] }}"
|
||||||
aria-label="{{ $label }}"
|
style="background-color: {{ $value['hex'] ?? '#cccccc' }};"
|
||||||
|
aria-label="{{ $value['label'] }}"
|
||||||
aria-pressed="false"
|
aria-pressed="false"
|
||||||
title="{{ $label }}"
|
|
||||||
></button>
|
></button>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@props([
|
||||||
|
'accept' => null,
|
||||||
|
'required' => false,
|
||||||
|
'disabled' => false,
|
||||||
|
])
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
@if ($accept) accept="{{ $accept }}" @endif
|
||||||
|
@required($required)
|
||||||
|
@disabled($disabled)
|
||||||
|
{{ $attributes->merge([
|
||||||
|
'class' => 'w-full py-2 text-sm
|
||||||
|
file:mr-4 file:py-2 file:px-4 file:rounded-none file:border file:border-black file:bg-transparent
|
||||||
|
file:font-semibold file:cursor-pointer file:transition-colors
|
||||||
|
hover:file:bg-black hover:file:text-neutral-200
|
||||||
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-black
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
]) }}
|
||||||
|
/>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$fillIcons = ['facebook', 'instagram', 'tiktok', 'search', 'bag'];
|
$fillIcons = ['facebook', 'instagram', 'tiktok', 'search', 'bag', 'user', 'heart', 'heart-fill'];
|
||||||
$svgStroke = $stroke ?? (in_array($name, $fillIcons) ? 'none' : $color);
|
$svgStroke = $stroke ?? (in_array($name, $fillIcons) ? 'none' : $color);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@@ -16,6 +16,12 @@
|
|||||||
|
|
||||||
'bag' => '<path stroke-width=0.5 d="M20.0049 22H4.00488C3.4526 22 3.00488 21.5523 3.00488 21V3C3.00488 2.44772 3.4526 2 4.00488 2H20.0049C20.5572 2 21.0049 2.44772 21.0049 3V21C21.0049 21.5523 20.5572 22 20.0049 22ZM19.0049 20V4H5.00488V20H19.0049ZM9.00488 6V8C9.00488 9.65685 10.348 11 12.0049 11C13.6617 11 15.0049 9.65685 15.0049 8V6H17.0049V8C17.0049 10.7614 14.7663 13 12.0049 13C9.24346 13 7.00488 10.7614 7.00488 8V6H9.00488Z"/>',
|
'bag' => '<path stroke-width=0.5 d="M20.0049 22H4.00488C3.4526 22 3.00488 21.5523 3.00488 21V3C3.00488 2.44772 3.4526 2 4.00488 2H20.0049C20.5572 2 21.0049 2.44772 21.0049 3V21C21.0049 21.5523 20.5572 22 20.0049 22ZM19.0049 20V4H5.00488V20H19.0049ZM9.00488 6V8C9.00488 9.65685 10.348 11 12.0049 11C13.6617 11 15.0049 9.65685 15.0049 8V6H17.0049V8C17.0049 10.7614 14.7663 13 12.0049 13C9.24346 13 7.00488 10.7614 7.00488 8V6H9.00488Z"/>',
|
||||||
|
|
||||||
|
'user' => '<path stroke-width=0.5 d="M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z"/>',
|
||||||
|
|
||||||
|
'heart' => '<path stroke-width=0.5 d="M12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736C22.5053 7.02472 22.583 10.637 20.4786 12.993L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853ZM18.827 6.1701C17.3279 4.66794 14.9076 4.60701 13.337 6.01687L12.0019 7.21524L10.6661 6.01781C9.09098 4.60597 6.67506 4.66808 5.17157 6.17157C3.68183 7.66131 3.60704 10.0473 4.97993 11.6232L11.9999 18.6543L19.0201 11.6232C20.3935 10.0467 20.319 7.66525 18.827 6.1701Z"/>',
|
||||||
|
|
||||||
|
'heart-fill' => '<path stroke-width=2 d="M12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736C22.5053 7.02472 22.583 10.637 20.4786 12.993L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853Z"/>',
|
||||||
|
|
||||||
'close' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" d="M6 6L18 18M18 6L6 18"/>',
|
'close' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" d="M6 6L18 18M18 6L6 18"/>',
|
||||||
|
|
||||||
'arrow-left' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M19 12H5M5 12L11 6M5 12L11 18"/>',
|
'arrow-left' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M19 12H5M5 12L11 6M5 12L11 18"/>',
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{-- $values: one product option's de-duplicated, ordered values, as built by
|
||||||
|
ProductController::buildOptionPicker() — [{id, label, hex}]. Use this
|
||||||
|
instead of <x-ui.color-swatch> for options that aren't a color (no hex),
|
||||||
|
where a swatch dot has nothing meaningful to show. --}}
|
||||||
|
@props(['values', 'option' => null, 'optionHandle' => null])
|
||||||
|
|
||||||
|
<div {{ $attributes }}>
|
||||||
|
@if($option)
|
||||||
|
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
|
||||||
|
{{ $option }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
role="group"
|
||||||
|
aria-label="{{ $option ?? 'Option' }}"
|
||||||
|
>
|
||||||
|
@foreach($values as $value)
|
||||||
|
<x-ui.button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
data-product-form-target="swatch"
|
||||||
|
data-action="click->product-form#selectVariant"
|
||||||
|
data-option="{{ $optionHandle }}"
|
||||||
|
data-value-id="{{ $value['id'] }}"
|
||||||
|
aria-label="{{ $value['label'] }}"
|
||||||
|
aria-pressed="false"
|
||||||
|
>{{ $value['label'] }}</x-ui.button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
'price' => null,
|
'price' => null,
|
||||||
'image' => null,
|
'image' => null,
|
||||||
'href' => '#',
|
'href' => '#',
|
||||||
|
'variantId' => null,
|
||||||
|
'hasCustomFields' => false,
|
||||||
|
// Shows the "sold" star and drops the quick add-to-cart/personalize button.
|
||||||
|
'soldOut' => false,
|
||||||
|
// Heart toggle at the top right of the image; needs productId.
|
||||||
|
'productId' => null,
|
||||||
|
'wishlist' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
{{-- data-turbo-frame="_top" on the links: this card renders inside the
|
{{-- data-turbo-frame="_top" on the links: this card renders inside the
|
||||||
@@ -27,9 +34,37 @@ class="w-full h-auto block"
|
|||||||
@endif
|
@endif
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<x-ui.button size="md" position="absolute" class="opacity-0 group-hover:opacity-100 transition-opacity duration-100">
|
@if ($wishlist && $productId)
|
||||||
|
<x-wishlist-button :product-id="$productId" class="absolute top-4 right-4 z-10" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($soldOut)
|
||||||
|
<x-ui.sold-badge class="absolute top-2 right-2 {{ $wishlist && $productId ? 'mt-12' : '' }}" />
|
||||||
|
@elseif ($hasCustomFields)
|
||||||
|
{{-- Custom fields have to be filled in on the product page. --}}
|
||||||
|
<x-ui.button
|
||||||
|
:href="$href"
|
||||||
|
size="md"
|
||||||
|
position="absolute"
|
||||||
|
data-turbo-frame="_top"
|
||||||
|
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity duration-100"
|
||||||
|
>
|
||||||
|
{{ __('storefront.product.personalize') }}
|
||||||
|
</x-ui.button>
|
||||||
|
@elseif ($variantId)
|
||||||
|
{{-- has-[...] forces the button visible while an add-to-cart error
|
||||||
|
is showing, so it isn't only readable on hover — a shopper who
|
||||||
|
already moved off the card (mouse or the click itself) must
|
||||||
|
still see why nothing happened. --}}
|
||||||
|
<x-checkout::add-to-cart
|
||||||
|
:purchasable="$variantId"
|
||||||
|
class="absolute opacity-0 group-hover:opacity-100 has-[.bbk-add-to-cart-error:not([hidden])]:opacity-100 transition-opacity duration-100"
|
||||||
|
>
|
||||||
|
<x-ui.button type="submit" size="md">
|
||||||
{{ __('storefront.product.add_to_cart') }}
|
{{ __('storefront.product.add_to_cart') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
|
</x-checkout::add-to-cart>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-baseline justify-between gap-4">
|
<div class="flex items-baseline justify-between gap-4">
|
||||||
|
|||||||
@@ -3,15 +3,20 @@
|
|||||||
'value' => null,
|
'value' => null,
|
||||||
'name' => null,
|
'name' => null,
|
||||||
'ariaLabel' => null,
|
'ariaLabel' => null,
|
||||||
|
'placeholder' => null,
|
||||||
|
'block' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
<div class="relative inline-flex items-center">
|
<div @class(['relative items-center', 'flex w-full' => $block, 'inline-flex' => ! $block])>
|
||||||
<select
|
<select
|
||||||
@if($name) name="{{ $name }}" @endif
|
@if($name) name="{{ $name }}" @endif
|
||||||
@if($ariaLabel) aria-label="{{ $ariaLabel }}" @endif
|
@if($ariaLabel) aria-label="{{ $ariaLabel }}" @endif
|
||||||
{{ $attributes->merge(['class' => 'appearance-none bg-transparent border-b border-black pr-10 py-2.5 cursor-pointer focus:outline-none']) }}
|
{{ $attributes->merge(['class' => 'appearance-none bg-transparent border-b border-black pr-10 py-2.5 cursor-pointer focus:outline-none'.($block ? ' w-full' : '')]) }}
|
||||||
>
|
>
|
||||||
|
@if($placeholder !== null)
|
||||||
|
<option value="" @selected($value === null || $value === '')>{{ $placeholder }}</option>
|
||||||
|
@endif
|
||||||
@foreach($options as $option)
|
@foreach($options as $option)
|
||||||
<option value="{{ $option['value'] }}" @selected($value === $option['value'])>{{ $option['label'] }}</option>
|
<option value="{{ $option['value'] }}" @selected($value === $option['value'])>{{ $option['label'] }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{{-- Black "sold" star for out-of-stock products. Position it from the caller
|
||||||
|
(e.g. class="absolute top-2 right-2"). --}}
|
||||||
|
<div {{ $attributes->class('z-10 flex h-20 w-22 items-center justify-center text-black') }}>
|
||||||
|
<svg viewBox="0 0 87 80" class="absolute inset-0 size-full" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M56.942,0l2.607,18.642,19.143-3.363L69.468,31.842,87,40,69.468,48.158l9.224,16.563L59.549,61.358,56.942,80,43.5,66.4,30.058,80,27.451,61.358,8.308,64.721l9.224-16.563L0,40l17.532-8.158L8.308,15.279l19.143,3.363L30.058,0,43.5,13.6Z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="relative text-base italic font-black text-white uppercase">{{ __('storefront.product.sold') }}</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{--
|
||||||
|
Flash confirmation box ("Changes saved." etc.). Renders nothing when there's
|
||||||
|
no message; defaults to session('status'). Spacing comes from the caller.
|
||||||
|
--}}
|
||||||
|
@props([
|
||||||
|
'message' => session('status'),
|
||||||
|
])
|
||||||
|
|
||||||
|
@if (filled($message))
|
||||||
|
<p role="status" aria-live="polite" {{ $attributes->merge(['class' => 'border border-black bg-brand p-6 text-black']) }}>{{ $message }}</p>
|
||||||
|
@endif
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
@props(['tabs' => [], 'size' => 'md'])
|
@props(['tabs' => [], 'size' => 'md', 'active' => null])
|
||||||
@php
|
@php
|
||||||
$sizeClasses = match($size) {
|
$sizeClasses = match($size) {
|
||||||
'lg' => 'text-[36px] font-extrabold [--slide-h:5px]',
|
'lg' => 'text-[36px] font-extrabold [--slide-h:5px]',
|
||||||
default => 'text-xl font-bold [--slide-h:3px]',
|
default => 'text-xl font-bold [--slide-h:3px]',
|
||||||
};
|
};
|
||||||
|
$activeId = $active ?? ($tabs[0]['id'] ?? null);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
{{--
|
{{--
|
||||||
@@ -21,22 +22,22 @@
|
|||||||
|
|
||||||
<div data-controller="tabs" {{ $attributes }}>
|
<div data-controller="tabs" {{ $attributes }}>
|
||||||
<div class="flex gap-10 mb-8" role="tablist">
|
<div class="flex gap-10 mb-8" role="tablist">
|
||||||
@foreach($tabs as $i => $tab)
|
@foreach($tabs as $tab)
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
data-tabs-target="button"
|
data-tabs-target="button"
|
||||||
data-action="click->tabs#show"
|
data-action="click->tabs#show"
|
||||||
data-panel="{{ $tab['id'] }}"
|
data-panel="{{ $tab['id'] }}"
|
||||||
class="underline-slide font-display pb-1 {{ $sizeClasses }} {{ $i === 0 ? 'is-active' : '' }}"
|
class="underline-slide font-display pb-1 {{ $sizeClasses }} {{ $tab['id'] === $activeId ? 'is-active' : '' }}"
|
||||||
aria-selected="{{ $i === 0 ? 'true' : 'false' }}"
|
aria-selected="{{ $tab['id'] === $activeId ? 'true' : 'false' }}"
|
||||||
aria-controls="tab-panel-{{ $tab['id'] }}"
|
aria-controls="tab-panel-{{ $tab['id'] }}"
|
||||||
id="tab-btn-{{ $tab['id'] }}"
|
id="tab-btn-{{ $tab['id'] }}"
|
||||||
>{{ $tab['label'] }}</button>
|
>{{ $tab['label'] }}</button>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@foreach($tabs as $i => $tab)
|
@foreach($tabs as $tab)
|
||||||
@php $panelKey = $tab['id']; $panel = $$panelKey ?? null; @endphp
|
@php $panelKey = $tab['id']; $panel = $$panelKey ?? null; @endphp
|
||||||
<div
|
<div
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
@@ -44,7 +45,7 @@ class="underline-slide font-display pb-1 {{ $sizeClasses }} {{ $i === 0 ? 'is-ac
|
|||||||
id="tab-panel-{{ $tab['id'] }}"
|
id="tab-panel-{{ $tab['id'] }}"
|
||||||
aria-labelledby="tab-btn-{{ $tab['id'] }}"
|
aria-labelledby="tab-btn-{{ $tab['id'] }}"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@if($i !== 0) hidden @endif
|
@if($tab['id'] !== $activeId) hidden @endif
|
||||||
>{{ $panel }}</div>
|
>{{ $panel }}</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{{--
|
||||||
|
Heart toggle for one product. A real form, so it works without JS; the
|
||||||
|
`wishlist` Stimulus controller turns the submit into a fetch and flips
|
||||||
|
aria-pressed, which the CSS (group-aria-pressed) uses to swap the icons.
|
||||||
|
|
||||||
|
Usage: <x-wishlist-button :product-id="$product['id']" class="absolute top-4 right-4" />
|
||||||
|
--}}
|
||||||
|
@props(['productId'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$active = app(\Modules\Core\Wishlist\Services\WishlistService::class)->has((int) $productId);
|
||||||
|
$addLabel = __('storefront.wishlist.add');
|
||||||
|
$removeLabel = __('storefront.wishlist.remove');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="{{ route('wishlist.toggle', ['productId' => $productId]) }}"
|
||||||
|
data-controller="wishlist"
|
||||||
|
data-action="submit->wishlist#toggle"
|
||||||
|
data-wishlist-add-label-value="{{ $addLabel }}"
|
||||||
|
data-wishlist-remove-label-value="{{ $removeLabel }}"
|
||||||
|
data-wishlist-added-message-value="{{ __('storefront.wishlist.added') }}"
|
||||||
|
data-wishlist-removed-message-value="{{ __('storefront.wishlist.removed') }}"
|
||||||
|
{{ $attributes }}
|
||||||
|
>
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="group/heart flex size-12 cursor-pointer items-center justify-center"
|
||||||
|
aria-pressed="{{ $active ? 'true' : 'false' }}"
|
||||||
|
aria-label="{{ $active ? $removeLabel : $addLabel }}"
|
||||||
|
data-wishlist-target="button"
|
||||||
|
>
|
||||||
|
<x-ui.icon name="heart" :size="30" class="group-aria-pressed/heart:hidden" />
|
||||||
|
<x-ui.icon name="heart-fill" :size="28" stroke="#000" class="hidden group-aria-pressed/heart:block text-brand" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="sr-only" role="status" aria-live="polite" data-wishlist-target="status"></span>
|
||||||
|
</form>
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
@section('title', __('storefront.nav.contact'))
|
@section('title', __('storefront.nav.contact'))
|
||||||
@section('description', 'Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό.')
|
@section('description', 'Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό.')
|
||||||
|
|
||||||
|
{{-- Only on pages with a captcha, not in the global layout. --}}
|
||||||
|
@push('scripts')
|
||||||
|
<script src="https://js.hcaptcha.com/1/api.js?hl={{ app()->getLocale() }}" async defer></script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
<section class="grid grid-cols-1 lg:grid-cols-2 lg:divide-x lg:divide-black lg:min-h-[calc(100vh-102px)]">
|
<section class="grid grid-cols-1 lg:grid-cols-2 lg:divide-x lg:divide-black lg:min-h-[calc(100vh-102px)]">
|
||||||
@@ -24,23 +29,45 @@
|
|||||||
Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό.
|
Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST" action="#" class="flex flex-col gap-8 max-w-xl">
|
<x-ui.status class="max-w-xl" />
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('contact.send') }}" class="flex flex-col gap-8 max-w-xl">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||||
<x-ui.field label="Το όνομά σου" for="contact-name">
|
<x-ui.field label="Το όνομά σου *" for="contact-name" :error="$errors->first('name')">
|
||||||
<x-ui.input id="contact-name" name="name" autocomplete="name" :required="true" />
|
<x-ui.input id="contact-name" name="name" autocomplete="name" maxlength="100" :value="old('name')" :required="true"
|
||||||
|
:aria-invalid="$errors->has('name') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('name') ? 'contact-name-error' : null" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
|
|
||||||
<x-ui.field label="Το email σου" for="contact-email">
|
<x-ui.field label="Το email σου *" for="contact-email" :error="$errors->first('email')">
|
||||||
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" :required="true" />
|
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" maxlength="255" :value="old('email')" :required="true"
|
||||||
|
:aria-invalid="$errors->has('email') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('email') ? 'contact-email-error' : null" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-ui.field label="Το μήνυμά σου" for="contact-message">
|
<x-ui.field label="Το μήνυμά σου *" for="contact-message" :error="$errors->first('message')">
|
||||||
<x-ui.textarea id="contact-message" name="message" rows="5" :required="true" />
|
<x-ui.textarea id="contact-message" name="message" rows="5" maxlength="5000" :required="true"
|
||||||
|
:aria-invalid="$errors->has('message') ? 'true' : null"
|
||||||
|
:aria-describedby="$errors->has('message') ? 'contact-message-error' : null">{{ old('message') }}</x-ui.textarea>
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
|
|
||||||
|
{{-- hCaptcha checkbox, verified by App\Rules\HCaptcha (see auth/login). --}}
|
||||||
|
<x-ui.field for="contact-captcha" :error="$errors->first('h-captcha-response')">
|
||||||
|
<div
|
||||||
|
id="contact-captcha"
|
||||||
|
class="h-captcha min-h-[78px]"
|
||||||
|
data-sitekey="{{ config('services.hcaptcha.sitekey') }}"
|
||||||
|
@if ($errors->has('h-captcha-response')) aria-describedby="contact-captcha-error" @endif
|
||||||
|
></div>
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
|
@if (session('contact_error'))
|
||||||
|
<p role="alert" class="text-sm text-red-600">{{ session('contact_error') }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-ui.button type="submit" size="md">Υποβολή</x-ui.button>
|
<x-ui.button type="submit" size="md">Υποβολή</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
|
@section('title', __('storefront.contact.confirmation_subject'))
|
||||||
|
|
||||||
|
@section('preheader', __('storefront.contact.confirmation_preheader'))
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||||
|
{{ __('storefront.contact.confirmation_heading') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:0;">{{ __('storefront.contact.confirmation_body') }}</p>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
<p style="margin:0;">{{ __('storefront.contact.confirmation_footer') }}</p>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
|
@section('title', 'Νέο μήνυμα επικοινωνίας')
|
||||||
|
|
||||||
|
@section('preheader', "Από {$senderName} ({$senderEmail})")
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||||
|
Νέο μήνυμα επικοινωνίας
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px;"><strong>Όνομα:</strong> {{ $senderName }}</p>
|
||||||
|
<p style="margin:0 0 4px;"><strong>Email:</strong> <a href="mailto:{{ $senderEmail }}" style="color:#000000;">{{ $senderEmail }}</a></p>
|
||||||
|
<p style="margin:0 0 24px;"><strong>Γλώσσα:</strong> {{ strtoupper($senderLocale) }}</p>
|
||||||
|
|
||||||
|
<p style="margin:0;white-space:pre-line;">{{ $body }}</p>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
<p style="margin:0;">Στάλθηκε από τη φόρμα επικοινωνίας του 3dealer. Απάντησε σε αυτό το email για να απαντήσεις στον αποστολέα.</p>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
|
<title>@yield('title', '3Dealer')</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<style>
|
||||||
|
table { border-collapse: collapse !important; }
|
||||||
|
.email-container { width: 600px !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet" type="text/css">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style>
|
||||||
|
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||||
|
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||||
|
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
||||||
|
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; background-color: #e5e5e5; }
|
||||||
|
a[x-apple-data-detectors] {
|
||||||
|
color: inherit !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-size: inherit !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
font-weight: inherit !important;
|
||||||
|
line-height: inherit !important;
|
||||||
|
}
|
||||||
|
#MessageViewBody, #MessageWebViewDiv { width: 100% !important; }
|
||||||
|
u + #email-body a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
@media only screen and (max-width: 480px) {
|
||||||
|
.email-container { width: 100% !important; max-width: 100% !important; }
|
||||||
|
.email-header { padding: 24px 20px 12px !important; }
|
||||||
|
.email-gutter { padding: 0 !important; }
|
||||||
|
.email-card { padding: 28px 20px !important; }
|
||||||
|
.email-footer { padding: 0 20px 32px !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body id="email-body" style="margin:0;padding:0;background-color:#e5e5e5;">
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#e5e5e5;">
|
||||||
|
@yield('preheader', '')
|
||||||
|
‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<center role="article" aria-roledescription="email" style="width:100%;background-color:#e5e5e5;">
|
||||||
|
<!--[if mso]>
|
||||||
|
<table role="presentation" width="600" align="center" cellpadding="0" cellspacing="0" bgcolor="#e5e5e5"><tr><td>
|
||||||
|
<![endif]-->
|
||||||
|
<table role="presentation" class="email-container" width="100%" cellpadding="0" cellspacing="0" bgcolor="#e5e5e5" style="max-width:600px;margin:0 auto;">
|
||||||
|
<tr>
|
||||||
|
<td class="email-header" align="center" style="padding:32px 24px 16px;">
|
||||||
|
<a href="{{ url('/') }}" target="_blank" style="text-decoration:none;">
|
||||||
|
<img src="{{ asset('images/logo.png') }}" width="140" height="123" alt="3Dealer" style="display:inline-block;border:0;outline:none;text-decoration:none;height:auto;max-width:140px;width:100%;">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="email-gutter" style="padding:0 24px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#ffffff" style="background-color:#ffffff;border-radius:0;">
|
||||||
|
<tr>
|
||||||
|
<td class="email-card" style="padding:40px 32px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#000000;text-align:left;">
|
||||||
|
@yield('content')
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="email-footer" align="center" style="padding:24px 24px 40px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:13px;line-height:1.6;color:#7a7a7a;">
|
||||||
|
@yield('footer')
|
||||||
|
<p style="margin:12px 0 0;">
|
||||||
|
<a href="{{ url('/') }}" style="color:#7a7a7a;text-decoration:underline;">3dealer.gr</a>
|
||||||
|
· © {{ date('Y') }} 3Dealer
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if mso]>
|
||||||
|
</td></tr></table>
|
||||||
|
<![endif]-->
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{--
|
||||||
|
@include('emails.partials.address', ['address' => $order->shippingAddress])
|
||||||
|
|
||||||
|
An order address, read-only, same fields as the account order page.
|
||||||
|
--}}
|
||||||
|
<p style="margin:0;font-size:15px;line-height:1.6;">
|
||||||
|
{{ trim($address->first_name.' '.$address->last_name) }}<br>
|
||||||
|
@if ($address->company_name){{ $address->company_name }}<br>@endif
|
||||||
|
@if ($address->tax_identifier)ΑΦΜ: {{ $address->tax_identifier }}<br>@endif
|
||||||
|
{{ $address->line_one }}<br>
|
||||||
|
@if ($address->line_two){{ $address->line_two }}<br>@endif
|
||||||
|
{{ trim($address->postcode.' '.$address->city) }}<br>
|
||||||
|
@if ($address->state){{ Lang::has("core::states.{$address->state}") ? __("core::states.{$address->state}") : $address->state }}<br>@endif
|
||||||
|
@if ($address->contact_phone){{ $address->contact_phone }}@endif
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{{--
|
||||||
|
@include('emails.partials.button', ['url' => ..., 'label' => ...])
|
||||||
|
|
||||||
|
The site's primary button (x-ui.button, .btn-primary): neutral-200 fill,
|
||||||
|
black border, bold italic uppercase, black offset shadow. The shadow is
|
||||||
|
built from table cells, not box-shadow (Gmail and Outlook drop it): a
|
||||||
|
black column on the right starting $offset down, and a black row at the
|
||||||
|
bottom starting $offset in. The button cell spans the first two rows, so
|
||||||
|
the black column always matches its height.
|
||||||
|
|
||||||
|
Uppercased here with the tonos removed, since emails can't run the site's
|
||||||
|
strip-accents script and CSS uppercase would keep the accents.
|
||||||
|
--}}
|
||||||
|
@php
|
||||||
|
$offset = 8;
|
||||||
|
$label = strtr(mb_strtoupper($label), [
|
||||||
|
'Ά' => 'Α', 'Έ' => 'Ε', 'Ή' => 'Η', 'Ί' => 'Ι', 'Ό' => 'Ο', 'Ύ' => 'Υ', 'Ώ' => 'Ω',
|
||||||
|
'ΐ' => 'Ϊ', 'ΰ' => 'Ϋ',
|
||||||
|
]);
|
||||||
|
$spacer = "font-size:1px;line-height:1px;mso-line-height-rule:exactly;";
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0;border-collapse:separate;">
|
||||||
|
<tr>
|
||||||
|
<td rowspan="2" bgcolor="#e5e5e5" style="background-color:#e5e5e5;border:1px solid #000000;border-radius:0;mso-padding-alt:14px 28px;">
|
||||||
|
<a href="{{ $url }}" target="_blank" style="display:inline-block;padding:14px 28px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:16px;line-height:20px;mso-line-height-rule:exactly;font-weight:700;font-style:italic;color:#000000;text-decoration:none;">{{ $label }}</a>
|
||||||
|
</td>
|
||||||
|
<td width="{{ $offset }}" height="{{ $offset }}" style="width:{{ $offset }}px;height:{{ $offset }}px;{{ $spacer }}"> </td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="{{ $offset }}" bgcolor="#000000" style="width:{{ $offset }}px;background-color:#000000;{{ $spacer }}"> </td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td height="{{ $offset }}" style="height:{{ $offset }}px;padding:0 0 0 {{ $offset }}px;{{ $spacer }}">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td height="{{ $offset }}" bgcolor="#000000" style="height:{{ $offset }}px;background-color:#000000;{{ $spacer }}"> </td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td width="{{ $offset }}" height="{{ $offset }}" bgcolor="#000000" style="width:{{ $offset }}px;height:{{ $offset }}px;background-color:#000000;{{ $spacer }}"> </td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{{-- @include('emails.partials.section-heading', ['text' => ...]) --}}
|
||||||
|
<h2 style="margin:0 0 12px;padding:0 0 8px;border-bottom:1px solid #000000;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:18px;line-height:1.3;font-weight:800;color:#000000;">{{ $text }}</h2>
|
||||||
@@ -43,7 +43,7 @@ class="absolute left-4 lg:left-8 top-1/2 z-10 -translate-y-1/2 hover:-translate-
|
|||||||
class="{{ $loop->first ? '' : 'hidden' }} group h-full flex flex-col justify-center pb-8 relative"
|
class="{{ $loop->first ? '' : 'hidden' }} group h-full flex flex-col justify-center pb-8 relative"
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center">
|
<div class="relative flex items-center justify-center">
|
||||||
<a href="{{ $product['href'] }}" class="block border border-black bg-white flex items-center justify-center overflow-hidden max-w-xs xl:max-w-sm">
|
<a href="{{ $product['href'] }}" class="relative block border border-black bg-white flex items-center justify-center overflow-hidden max-w-xs xl:max-w-sm">
|
||||||
@if($product['image'])
|
@if($product['image'])
|
||||||
<img
|
<img
|
||||||
src="{{ $product['image'] }}"
|
src="{{ $product['image'] }}"
|
||||||
@@ -58,11 +58,34 @@ class="w-full h-full object-cover"
|
|||||||
@else
|
@else
|
||||||
<span class="text-neutral-500 text-sm">{{ __('storefront.product.no_image') }}</span>
|
<span class="text-neutral-500 text-sm">{{ __('storefront.product.no_image') }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- Inside the link so it sits on the image's corner, not the full-width slide's. --}}
|
||||||
|
@if ($product['soldOut'] ?? false)
|
||||||
|
<x-ui.sold-badge class="absolute top-2 right-2" />
|
||||||
|
@endif
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<x-ui.button size="md" position="absolute" class="opacity-0 group-hover:opacity-100 transition-opacity duration-100">
|
@if ($product['soldOut'] ?? false)
|
||||||
|
{{-- Sold: no quick add-to-cart/personalize, same as x-ui.product-card. --}}
|
||||||
|
@elseif ($product['hasCustomFields'] ?? false)
|
||||||
|
<x-ui.button
|
||||||
|
:href="$product['href']"
|
||||||
|
size="md"
|
||||||
|
position="absolute"
|
||||||
|
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity duration-100"
|
||||||
|
>
|
||||||
|
{{ __('storefront.product.personalize') }}
|
||||||
|
</x-ui.button>
|
||||||
|
@elseif ($product['variantId'] ?? null)
|
||||||
|
<x-checkout::add-to-cart
|
||||||
|
:purchasable="$product['variantId']"
|
||||||
|
class="absolute opacity-0 group-hover:opacity-100 has-[.bbk-add-to-cart-error:not([hidden])]:opacity-100 transition-opacity duration-100"
|
||||||
|
>
|
||||||
|
<x-ui.button type="submit" size="md">
|
||||||
{{ __('storefront.product.add_to_cart') }}
|
{{ __('storefront.product.add_to_cart') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
|
</x-checkout::add-to-cart>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-baseline justify-between gap-4 absolute bottom-0 left-0 w-full">
|
<div class="flex items-baseline justify-between gap-4 absolute bottom-0 left-0 w-full">
|
||||||
@@ -155,6 +178,9 @@ class="max-w-xs"
|
|||||||
:price="$product['price']"
|
:price="$product['price']"
|
||||||
:image="$product['image']"
|
:image="$product['image']"
|
||||||
:href="$product['href']"
|
:href="$product['href']"
|
||||||
|
:variant-id="$product['variantId'] ?? null"
|
||||||
|
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||||
|
:sold-out="$product['soldOut'] ?? false"
|
||||||
/>
|
/>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +195,5 @@ class="max-w-xs"
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-20">
|
|
||||||
<x-newsletter-split />
|
<x-newsletter-split />
|
||||||
</div>
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{{--
|
||||||
|
Account area: full-width, fixed-width sidebar + fluid content. Pages set
|
||||||
|
@section('title') and fill @section('account').
|
||||||
|
|
||||||
|
Below lg the sidebar becomes a horizontally scrollable row above the
|
||||||
|
content.
|
||||||
|
--}}
|
||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@push('seo')
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@php
|
||||||
|
$accountNav = [
|
||||||
|
[
|
||||||
|
'label' => __('storefront.account.nav_profile'),
|
||||||
|
'href' => route('account'),
|
||||||
|
'active' => request()->routeIs('account', 'account.email.*'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => __('storefront.account.nav_orders'),
|
||||||
|
'href' => route('account.orders'),
|
||||||
|
'active' => request()->routeIs('account.orders*'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => __('storefront.account.nav_wishlist'),
|
||||||
|
'href' => route('account.wishlist'),
|
||||||
|
'active' => request()->routeIs('account.wishlist'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$accountNavLink = 'nav-link uppercase inline-flex shrink-0 whitespace-nowrap font-display font-extrabold italic text-black no-underline';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row lg:min-h-[calc(100vh-102px)]">
|
||||||
|
|
||||||
|
<aside class="border-b border-black lg:w-80 lg:shrink-0 lg:border-b-0 lg:border-r">
|
||||||
|
<nav aria-label="{{ __('storefront.nav.account') }}">
|
||||||
|
<ul class="flex gap-8 overflow-x-auto px-4 py-5 sm:px-8 lg:flex-col lg:gap-5 lg:overflow-visible lg:px-10 lg:py-12">
|
||||||
|
@foreach ($accountNav as $item)
|
||||||
|
<li class="shrink-0">
|
||||||
|
<a
|
||||||
|
href="{{ $item['href'] }}"
|
||||||
|
@class([$accountNavLink, 'is-active' => $item['active']])
|
||||||
|
@if ($item['active']) aria-current="page" @endif
|
||||||
|
><span>{{ $item['label'] }}</span></a>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
<li class="shrink-0">
|
||||||
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="{{ $accountNavLink }} cursor-pointer">
|
||||||
|
<span>{{ __('storefront.auth.logout') }}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1 px-4 py-12 sm:px-8 lg:px-16 lg:py-16">
|
||||||
|
@yield('account')
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="3dealer" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
|
||||||
<title>@yield('title', config('app.name'))</title>
|
<title>@yield('title', config('app.name'))</title>
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-700.woff2') }}">
|
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-700.woff2') }}">
|
||||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-800.woff2') }}">
|
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ \Illuminate\Support\Facades\Vite::asset('resources/fonts/manrope/manrope-v20-greek_latin-800.woff2') }}">
|
||||||
|
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['node_modules/@boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
@auth('staff')
|
@auth('staff')
|
||||||
@php($stoicToken = \App\Services\Stoic::token(auth('staff')->user()->email))
|
@php($stoicToken = \App\Services\Stoic::token(auth('staff')->user()->email))
|
||||||
@@ -52,6 +53,14 @@
|
|||||||
<body class="min-h-screen antialiased">
|
<body class="min-h-screen antialiased">
|
||||||
<x-header />
|
<x-header />
|
||||||
|
|
||||||
|
{{-- Not on the checkout page itself — its order-summary column already
|
||||||
|
plays the drawer's role there, and both reacting to the same
|
||||||
|
`bbk-cart:changed` event would pop the (redundant) drawer open over
|
||||||
|
the page every time a line's quantity changes in the summary. --}}
|
||||||
|
@unless (request()->routeIs('checkout.show'))
|
||||||
|
@include('checkout::drawer')
|
||||||
|
@endunless
|
||||||
|
|
||||||
<main id="main-content" class="min-h-[calc(100vh-12rem)] sm:min-h-[calc(100vh-8rem)]">
|
<main id="main-content" class="min-h-[calc(100vh-12rem)] sm:min-h-[calc(100vh-8rem)]">
|
||||||
@yield('content')
|
@yield('content')
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -17,13 +17,20 @@
|
|||||||
class="grid grid-cols-1 md:grid-cols-2 gap-12"
|
class="grid grid-cols-1 md:grid-cols-2 gap-12"
|
||||||
data-controller="product-form"
|
data-controller="product-form"
|
||||||
data-product-form-variants-value="{{ json_encode($variantsData) }}"
|
data-product-form-variants-value="{{ json_encode($variantsData) }}"
|
||||||
|
data-product-form-stock-check-url-value="{{ route('product.stock-check', app()->getLocale()) }}"
|
||||||
|
data-product-form-stock-error-one-value="{{ trans_choice('storefront.product.add_to_cart_failed', 1) }}"
|
||||||
|
data-product-form-stock-error-many-value="{{ trans_choice('storefront.product.add_to_cart_failed', 2, ['count' => ':count']) }}"
|
||||||
>
|
>
|
||||||
|
|
||||||
{{-- Image --}}
|
{{-- Image --}}
|
||||||
|
{{-- relative: anchors the wishlist heart to the top right, which is
|
||||||
|
the main image's corner (thumbnails sit on the left). --}}
|
||||||
<div
|
<div
|
||||||
data-controller="product-gallery"
|
data-controller="product-gallery"
|
||||||
class="flex gap-4 items-start"
|
class="relative flex gap-4 items-start"
|
||||||
>
|
>
|
||||||
|
<x-wishlist-button :product-id="$product['id']" class="absolute top-4 right-4 z-10" />
|
||||||
|
|
||||||
@if(!empty($product['media']))
|
@if(!empty($product['media']))
|
||||||
{{-- Thumbnails --}}
|
{{-- Thumbnails --}}
|
||||||
<div class="flex flex-col items-center gap-1 w-[116px] shrink-0 -mt-12.5">
|
<div class="flex flex-col items-center gap-1 w-[116px] shrink-0 -mt-12.5">
|
||||||
@@ -146,7 +153,15 @@ class="absolute bottom-6 right-8 text-white text-sm"
|
|||||||
{{ $product['name'] }}
|
{{ $product['name'] }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<x-reviews-stars :rating="$product['reviews']['average_rating'] ?? 0" :count="$product['reviews']['count']" :showCount="true" />
|
<x-reviews-stars
|
||||||
|
:rating="$product['reviews']['average_rating'] ?? 0"
|
||||||
|
:count="$product['reviews']['count']"
|
||||||
|
:showCount="true"
|
||||||
|
:linkable="true"
|
||||||
|
data-controller="tab-link"
|
||||||
|
data-tab-link-tabs-outlet="#product-tabs"
|
||||||
|
data-tab-link-panel-value="reviews"
|
||||||
|
/>
|
||||||
|
|
||||||
@if($product['price'] !== null)
|
@if($product['price'] !== null)
|
||||||
<p class="text-2xl font-bold" data-product-form-target="price">
|
<p class="text-2xl font-bold" data-product-form-target="price">
|
||||||
@@ -155,31 +170,63 @@ class="absolute bottom-6 right-8 text-white text-sm"
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$desc = strip_tags($product['description'] ?? '');
|
$desc = html_entity_decode(strip_tags($product['description'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
$descTruncated = Str::limit($desc, 137);
|
$descTruncated = Str::limit($desc, 137);
|
||||||
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
|
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
|
||||||
@endphp
|
@endphp
|
||||||
<div class="text-base leading-relaxed">
|
<div class="text-base leading-relaxed">
|
||||||
{{ $descTruncated }}
|
{{ $descTruncated }}
|
||||||
@if($descNeedsMore)
|
@if($descNeedsMore)
|
||||||
<a href="#tab-panel-description" class="underline-slide font-semibold whitespace-nowrap">{{ __('storefront.product.read_more') }}</a>
|
<a
|
||||||
|
href="#tab-panel-description"
|
||||||
|
class="underline-slide font-semibold whitespace-nowrap"
|
||||||
|
data-controller="tab-link"
|
||||||
|
data-tab-link-tabs-outlet="#product-tabs"
|
||||||
|
data-tab-link-panel-value="description"
|
||||||
|
data-action="click->tab-link#activate"
|
||||||
|
>{{ __('storefront.product.read_more') }}</a>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($option && !empty($product['variants']))
|
@foreach($productOptions as $productOption)
|
||||||
<x-ui.color-swatch :variants="$product['variants']" :option="$option" />
|
@if($productOption['isColor'])
|
||||||
|
<x-ui.color-swatch :values="$productOption['values']" :option="$productOption['label']" :option-handle="$productOption['handle']" />
|
||||||
|
@else
|
||||||
|
<x-ui.option-buttons :values="$productOption['values']" :option="$productOption['label']" :option-handle="$productOption['handle']" />
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@php
|
||||||
|
// Same initial pick as product-form-controller.js's connect()
|
||||||
|
// (?variant= or the first), so the button doesn't flip on load.
|
||||||
|
$initialVariant = collect($variantsData)->firstWhere('id', (int) request('variant')) ?? ($variantsData[0] ?? null);
|
||||||
|
@endphp
|
||||||
|
<x-checkout::add-to-cart
|
||||||
|
:purchasable="$initialVariant['id'] ?? null"
|
||||||
|
:quantity="false"
|
||||||
|
class="flex flex-col gap-6"
|
||||||
|
>
|
||||||
|
@if(!empty($product['custom_fields']))
|
||||||
|
<x-product-custom-fields :fields="$product['custom_fields']" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex items-stretch gap-10">
|
<div class="flex items-stretch gap-10">
|
||||||
<x-ui.quantity name="quantity" />
|
<x-ui.quantity name="quantity" />
|
||||||
<x-ui.button class="flex-1">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
<x-ui.button type="submit" class="flex-1" data-product-form-target="submit" :disabled="! ($initialVariant['inStock'] ?? true)">{{ __('storefront.product.add_to_cart') }}</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
|
</x-checkout::add-to-cart>
|
||||||
|
|
||||||
|
{{-- Storefront-owned, not part of the checkout module — the
|
||||||
|
local stock pre-check in product-form-controller.js stops
|
||||||
|
an over-limit submit before it ever reaches the module
|
||||||
|
and reports it here. --}}
|
||||||
|
<p class="text-sm text-red-600" data-product-form-target="stockError" hidden role="alert"></p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-ui.tabs class="mt-16" size="lg" :tabs="[
|
<x-ui.tabs id="product-tabs" class="mt-16 scroll-mt-28" size="lg" :active="request('tab')" :tabs="[
|
||||||
['id' => 'description', 'label' => __('storefront.product.description')],
|
['id' => 'description', 'label' => __('storefront.product.description')],
|
||||||
['id' => 'reviews', 'label' => __('storefront.product.reviews') . ' (' . $product['reviews']['count'] . ')'],
|
['id' => 'reviews', 'label' => __('storefront.product.reviews') . ' (' . $product['reviews']['count'] . ')'],
|
||||||
]">
|
]">
|
||||||
@@ -194,6 +241,10 @@ class="absolute bottom-6 right-8 text-white text-sm"
|
|||||||
</ul>
|
</ul>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
<x-slot name="reviews">
|
<x-slot name="reviews">
|
||||||
|
@if(session('reviewSubmitted'))
|
||||||
|
<p class="mb-8 text-sm font-semibold" role="status">{{ __('storefront.review.thank_you') }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if(!empty($product['reviews']['items']))
|
@if(!empty($product['reviews']['items']))
|
||||||
<div class="mb-10">
|
<div class="mb-10">
|
||||||
@foreach($product['reviews']['items'] as $review)
|
@foreach($product['reviews']['items'] as $review)
|
||||||
@@ -203,6 +254,8 @@ class="absolute bottom-6 right-8 text-white text-sm"
|
|||||||
'date' => $review['reviewed_at'] ? \Illuminate\Support\Carbon::createFromTimestamp($review['reviewed_at'])->translatedFormat('d M Y') : '',
|
'date' => $review['reviewed_at'] ? \Illuminate\Support\Carbon::createFromTimestamp($review['reviewed_at'])->translatedFormat('d M Y') : '',
|
||||||
'text' => $review['body'],
|
'text' => $review['body'],
|
||||||
'image' => $review['media'][0]['url'] ?? null,
|
'image' => $review['media'][0]['url'] ?? null,
|
||||||
|
'reply' => $review['reply'] ?? null,
|
||||||
|
'replyDate' => $review['replied_at'] ? \Illuminate\Support\Carbon::createFromTimestamp($review['replied_at'])->translatedFormat('d M Y') : null,
|
||||||
]" />
|
]" />
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@@ -222,7 +275,7 @@ class="absolute bottom-6 right-8 text-white text-sm"
|
|||||||
@if(!empty($product['recommendations']))
|
@if(!empty($product['recommendations']))
|
||||||
<x-product-grid
|
<x-product-grid
|
||||||
class="mt-20"
|
class="mt-20"
|
||||||
title="Σχετικά προϊόντα"
|
:title="__('storefront.product.related')"
|
||||||
:products="collect($product['recommendations'])->map(fn (array $recommendation) => [
|
:products="collect($product['recommendations'])->map(fn (array $recommendation) => [
|
||||||
'name' => $recommendation['name'],
|
'name' => $recommendation['name'],
|
||||||
'price' => $recommendation['price'],
|
'price' => $recommendation['price'],
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
@extends('layouts.app')
|
|
||||||
|
|
||||||
@section('title', $product->translateAttribute('name') . ' — ' . config('app.name'))
|
|
||||||
@section('description', $product->translateAttribute('description'))
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-8 py-12">
|
|
||||||
|
|
||||||
@php $collection = $product->collections->first(); @endphp
|
|
||||||
|
|
||||||
<x-breadcrumb class="mb-14 justify-end" :items="[
|
|
||||||
$collection
|
|
||||||
? ['label' => $collection->translateAttribute('name'), 'href' => '/category/' . $collection->id]
|
|
||||||
: ['label' => 'Products', 'href' => '/products'],
|
|
||||||
['label' => $product->translateAttribute('name')],
|
|
||||||
]" />
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-1 md:grid-cols-2 gap-12"
|
|
||||||
data-controller="product-form"
|
|
||||||
data-product-form-variants-value="{{ json_encode($variantsData) }}"
|
|
||||||
>
|
|
||||||
|
|
||||||
{{-- Image --}}
|
|
||||||
<div
|
|
||||||
data-controller="product-gallery"
|
|
||||||
class="flex gap-4 items-start"
|
|
||||||
>
|
|
||||||
@if($product->media->isNotEmpty())
|
|
||||||
{{-- Thumbnails --}}
|
|
||||||
<div class="flex flex-col items-center gap-1 w-[116px] shrink-0 -mt-12.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-action="click->product-gallery#scrollUp"
|
|
||||||
class="gallery-arrow w-full flex items-center justify-center py-2"
|
|
||||||
aria-label="Scroll thumbnails up"
|
|
||||||
><x-ui.icon name="arrow-up" :size="30" /></button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-product-gallery-target="track"
|
|
||||||
class="flex flex-col gap-4 overflow-hidden"
|
|
||||||
style="max-height: var(--gallery-height, 600px)"
|
|
||||||
>
|
|
||||||
@foreach($product->media as $i => $media)
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-action="click->product-gallery#select"
|
|
||||||
data-product-gallery-target="thumb"
|
|
||||||
data-src="{{ $media->getUrl() }}"
|
|
||||||
data-alt="{{ $product->translateAttribute('name') }}"
|
|
||||||
class="block w-full shrink-0 border border-black overflow-hidden "
|
|
||||||
aria-label="View image {{ $i + 1 }}"
|
|
||||||
aria-pressed="{{ $i === 0 ? 'true' : 'false' }}"
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- opacity-50 transition-opacity {{ $i === 0 ? 'opacity-100' : '' }}" -->
|
|
||||||
<img src="{{ $media->getUrl() }}" alt="" class="w-full h-auto object-cover" aria-hidden="true">
|
|
||||||
</button>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-action="click->product-gallery#scrollDown"
|
|
||||||
class="gallery-arrow w-full flex items-center justify-center py-2"
|
|
||||||
aria-label="Scroll thumbnails down"
|
|
||||||
><x-ui.icon name="arrow-down" :size="30" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Main image --}}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex-1 border border-black bg-white cursor-zoom-in block p-0"
|
|
||||||
data-action="click->product-gallery#openLightbox"
|
|
||||||
aria-label="View full size image"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
data-product-form-target="image"
|
|
||||||
data-product-gallery-target="main"
|
|
||||||
src="{{ $product->media->first()->getUrl() }}"
|
|
||||||
alt="{{ $product->translateAttribute('name') }}"
|
|
||||||
class="w-full h-auto block"
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{{-- Lightbox --}}
|
|
||||||
<div
|
|
||||||
id="product-lightbox"
|
|
||||||
popover
|
|
||||||
class="product-lightbox"
|
|
||||||
data-product-gallery-target="lightbox"
|
|
||||||
data-action="click->product-gallery#closeLightboxOnBackdrop"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Product image gallery"
|
|
||||||
aria-modal="true"
|
|
||||||
>
|
|
||||||
{{-- Prev --}}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-action="click->product-gallery#prevImage click->product-gallery#noop"
|
|
||||||
class="lightbox-arrow absolute left-8 top-1/2 -translate-y-1/2"
|
|
||||||
aria-label="Previous image"
|
|
||||||
><x-ui.icon name="arrow-left" :size="72" color="white" /></button>
|
|
||||||
|
|
||||||
{{-- Image + close button as a unit --}}
|
|
||||||
<div class="relative" data-action="click->product-gallery#noop">
|
|
||||||
<img
|
|
||||||
data-product-gallery-target="lightboxImage"
|
|
||||||
src=""
|
|
||||||
alt=""
|
|
||||||
class="max-h-[90vh] max-w-[80vw] object-contain block"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
popovertarget="product-lightbox"
|
|
||||||
popovertargetaction="hide"
|
|
||||||
class="lightbox-close absolute -top-10 -right-10"
|
|
||||||
aria-label="Close gallery"
|
|
||||||
><x-ui.icon name="close" :size="36" color="white" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Next --}}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-action="click->product-gallery#nextImage click->product-gallery#noop"
|
|
||||||
class="lightbox-arrow absolute right-8 top-1/2 -translate-y-1/2"
|
|
||||||
aria-label="Next image"
|
|
||||||
><x-ui.icon name="arrow-right" :size="72" color="white" /></button>
|
|
||||||
|
|
||||||
{{-- Counter --}}
|
|
||||||
<p
|
|
||||||
data-product-gallery-target="lightboxCounter"
|
|
||||||
class="absolute bottom-6 right-8 text-white text-sm"
|
|
||||||
aria-live="polite"
|
|
||||||
></p>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="w-full bg-neutral-300 flex items-center justify-center text-neutral-500 min-h-64">
|
|
||||||
No image
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Info --}}
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
|
|
||||||
<h1 class="font-display font-medium text-4xl lg:text-[54px]">
|
|
||||||
{{ $product->translateAttribute('name') }}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<x-reviews-stars :rating="3" :count="24" :showCount="true" />
|
|
||||||
|
|
||||||
@if($product->variants->first()?->prices->isNotEmpty())
|
|
||||||
<p class="text-2xl font-bold" data-product-form-target="price">
|
|
||||||
<x-ui.price :amount="$product->variants->first()->prices->first()->price->decimal" />
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@php
|
|
||||||
$desc = strip_tags($product->translateAttribute('description') ?? '');
|
|
||||||
$descTruncated = Str::limit($desc, 137);
|
|
||||||
$descNeedsMore = mb_strlen($desc) > mb_strlen(rtrim($descTruncated, '.'));
|
|
||||||
@endphp
|
|
||||||
<div class="text-base leading-relaxed">
|
|
||||||
{{ $descTruncated }}
|
|
||||||
@if($descNeedsMore)
|
|
||||||
<a href="#tab-panel-description" class="underline-slide font-semibold whitespace-nowrap">Περισσότερα</a>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($option && $product->variants->count() >= 1)
|
|
||||||
<x-ui.color-swatch :variants="$product->variants" :option="$option" />
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="flex items-stretch gap-10">
|
|
||||||
<x-ui.quantity name="quantity" />
|
|
||||||
<x-ui.button class="flex-1">Προσθήκη στο καλάθι</x-ui.button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-ui.tabs class="mt-16" size="lg" :tabs="[
|
|
||||||
['id' => 'description', 'label' => 'Περιγραφή'],
|
|
||||||
// ['id' => 'reviews', 'label' => 'Αξιολογήσεις (' . count($reviews) . ')'],
|
|
||||||
['id' => 'reviews', 'label' => 'Αξιολογήσεις (3)'],
|
|
||||||
]">
|
|
||||||
<x-slot name="description">
|
|
||||||
<div class="leading-7 [&_p]:mt-4">
|
|
||||||
{!! $product->translateAttribute('description') !!}
|
|
||||||
</div>
|
|
||||||
@if($product->translateAttribute('details'))
|
|
||||||
<div class="mt-6">{!! $product->translateAttribute('details') !!}</div>
|
|
||||||
@endif
|
|
||||||
<ul class="mt-8 flex flex-col gap-3 text-neutral-600 list-disc list-outside pl-5">
|
|
||||||
<li>Όλα τα προϊόντα εκτυπώνονται και προετοιμάζονται κατά παραγγελία. Ο χρόνος προετοιμασίας κυμαίνεται μεταξύ 2 και 7 εργάσιμων ημερών.</li>
|
|
||||||
<li>Όλα τα προϊόντα κατασκευάζονται με τρισδιάστατη εκτύπωση σε ειδικούς εκτυπωτές πλαστικού υλικού. Πιθανώς να έχουν εμφανείς γραμμές ένωσης, στρώσεις εκτύπωσης υλικού και μικρές ατέλειες. Είναι φυσιολογικό για το αποτέλεσμα αυτής της δημιουργικής διαδικασίας.</li>
|
|
||||||
<li>Όλα τα προϊόντα είναι σχεδιασμένα και κατασκευασμένα είτε για διακόσμηση είτε για χρήση σε λογικά, ρεαλιστικά πλαίσια. Υπερβολική ισχύς, υψηλότατες θερμοκρασίες και αποσυναρμολόγηση μπορεί να προκαλέσουν ζημιά στο προϊόν για την οποία δεν ευθύνεται το 3Dealer.</li>
|
|
||||||
</ul>
|
|
||||||
</x-slot>
|
|
||||||
<x-slot name="reviews">
|
|
||||||
{{-- @if(count($reviews) > 0)
|
|
||||||
<div class="mb-10">
|
|
||||||
@foreach($reviews as $review)
|
|
||||||
<x-review-card :review="$review" />
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<p class="text-neutral-500 mb-10">Δεν υπάρχουν αξιολογήσεις ακόμα.</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<h3 class="text-h4 font-bold">
|
|
||||||
{{ count($reviews) > 0 ? 'Πρόσθεσε μια' : 'Γράψε την πρώτη' }} αξιολόγηση για το «{{ $product->translateAttribute('name') }}»
|
|
||||||
</h3> --}}
|
|
||||||
|
|
||||||
<x-review-form :product="$product" />
|
|
||||||
</x-slot>
|
|
||||||
</x-ui.tabs>
|
|
||||||
|
|
||||||
<x-product-grid
|
|
||||||
class="mt-20"
|
|
||||||
title="Σχετικά προϊόντα"
|
|
||||||
:products="[
|
|
||||||
['name' => 'Camper', 'price' => '30', 'image' => null, 'href' => '#'],
|
|
||||||
['name' => 'Ponderer IPA','price' => '25', 'image' => null, 'href' => '#'],
|
|
||||||
['name' => 'Squish Red', 'price' => '35', 'image' => null, 'href' => '#'],
|
|
||||||
['name' => 'Red Light', 'price' => '25', 'image' => null, 'href' => '#'],
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-newsletter class="py-20" />
|
|
||||||
|
|
||||||
@endsection
|
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
@endpush
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
|
||||||
|
|
||||||
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
||||||
<h1 class="font-medium text-h2">{{ __('storefront.shop.all_products') }}</h1>
|
<h1 class="font-medium text-h2">{{ __('storefront.shop.all_products') }}</h1>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
@endpush
|
@endpush
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
|
||||||
|
|
||||||
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
||||||
<h1 class="font-medium text-h2">{{ $heading }}</h1>
|
<h1 class="font-medium text-h2">{{ $heading }}</h1>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
|
@section('title', 'Επιβεβαίωσε το νέο σου email')
|
||||||
|
|
||||||
|
@section('preheader', "Ο κωδικός επιβεβαίωσης: {$code}")
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||||
|
Επιβεβαίωσε το νέο σου email
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px;">Βάλε τον παρακάτω κωδικό στο 3dealer για να γίνει αυτή η διεύθυνση το νέο email του λογαριασμού σου.</p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#18c28a" style="background-color:#18c28a;border:1px solid #000000;border-radius:0;padding:20px;">
|
||||||
|
<span style="font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:32px;font-weight:800;letter-spacing:8px;color:#000000;">{{ $code }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px;color:#5c5c5c;font-size:14px;">Ο κωδικός ισχύει για 10 λεπτά.</p>
|
||||||
|
|
||||||
|
<p style="margin:0;color:#5c5c5c;font-size:14px;">Αν δεν ζήτησες εσύ αυτή την αλλαγή, αγνόησε αυτό το email. Τίποτα δεν θα αλλάξει.</p>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
<p style="margin:0;">Αυτό το email στάλθηκε επειδή ζητήθηκε αλλαγή email σε λογαριασμό του 3dealer.</p>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
|
@section('title', 'Το email του λογαριασμού σου άλλαξε')
|
||||||
|
|
||||||
|
@section('preheader', "Νέο email λογαριασμού: {$maskedEmail}")
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||||
|
Το email του λογαριασμού σου άλλαξε
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px;">Το email σύνδεσης του λογαριασμού σου στο 3dealer άλλαξε σε <strong>{{ $maskedEmail }}</strong>.</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px;">Από εδώ και πέρα οι κωδικοί σύνδεσης θα πηγαίνουν στη νέα διεύθυνση.</p>
|
||||||
|
|
||||||
|
<p style="margin:0;color:#5c5c5c;font-size:14px;">Αν δεν έκανες εσύ αυτή την αλλαγή, <a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#000000;">επικοινώνησε μαζί μας</a> άμεσα.</p>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
<p style="margin:0;">Αυτό το email στάλθηκε στην προηγούμενη διεύθυνση του λογαριασμού σου στο 3dealer.</p>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
|
@section('title', 'Ο κωδικός σύνδεσής σου')
|
||||||
|
|
||||||
|
@section('preheader', "Ο κωδικός σύνδεσής σου στο 3dealer: {$code}")
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||||
|
Ο κωδικός σύνδεσής σου
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px;">Γεια σου {{ $name }},</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px;">Χρησιμοποίησε τον παρακάτω κωδικό για να συνδεθείς στο διαχειριστικό του 3dealer.</p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#18c28a" style="background-color:#18c28a;border:1px solid #000000;border-radius:0;padding:20px;">
|
||||||
|
<span style="font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:32px;font-weight:800;letter-spacing:8px;color:#000000;">{{ $code }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px;color:#5c5c5c;font-size:14px;">Ο κωδικός ισχύει για περιορισμένο χρονικό διάστημα και μπορεί να χρησιμοποιηθεί μία μόνο φορά.</p>
|
||||||
|
|
||||||
|
<p style="margin:0;color:#5c5c5c;font-size:14px;">Αν δεν ζήτησες εσύ αυτόν τον κωδικό, αγνόησε αυτό το email — ο λογαριασμός σου παραμένει ασφαλής.</p>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
<p style="margin:0;">Αυτό το email στάλθηκε επειδή ζητήθηκε σύνδεση στο διαχειριστικό του 3dealer.</p>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
|
@section('title', 'Ο κωδικός σύνδεσής σου')
|
||||||
|
|
||||||
|
@section('preheader', "Ο κωδικός σύνδεσής σου στο 3dealer: {$code}")
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h1 style="margin:0 0 20px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||||
|
Ο κωδικός σύνδεσής σου
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{{-- A brand-new account has no name yet, and core falls back to the email — skip that. --}}
|
||||||
|
<p style="margin:0 0 16px;">Γεια σου{{ str_contains($name, '@') ? '' : ' '.$name }},</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 24px;">Χρησιμοποίησε τον παρακάτω κωδικό για να συνδεθείς στον λογαριασμό σου στο 3dealer.</p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#18c28a" style="background-color:#18c28a;border:1px solid #000000;border-radius:0;padding:20px;">
|
||||||
|
<span style="font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:32px;font-weight:800;letter-spacing:8px;color:#000000;">{{ $code }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px;color:#5c5c5c;font-size:14px;">Ο κωδικός ισχύει για 10 λεπτά και μπορεί να χρησιμοποιηθεί μία μόνο φορά.</p>
|
||||||
|
|
||||||
|
<p style="margin:0;color:#5c5c5c;font-size:14px;">Αν δεν ζήτησες εσύ αυτόν τον κωδικό, αγνόησε αυτό το email — ο λογαριασμός σου παραμένει ασφαλής.</p>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
<p style="margin:0;">Αυτό το email στάλθηκε επειδή ζητήθηκε σύνδεση στο 3dealer με αυτή τη διεύθυνση email.</p>
|
||||||
|
@endsection
|
||||||
@@ -1,3 +1,26 @@
|
|||||||
<p>Hi,</p>
|
{{-- Core passes only the order reference here (and the amount for payment emails). Greek on purpose: sent from the queue in the app default locale. --}}
|
||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
|
@section('title', "Λάβαμε την πληρωμή σου")
|
||||||
|
|
||||||
|
@section('preheader', "Λάβαμε την πληρωμή {$amount} για την παραγγελία #{$reference}.")
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<h1 style="margin:0 0 16px;font-family:'Manrope',Arial,Helvetica,sans-serif;font-size:22px;line-height:1.3;font-weight:700;color:#000000;">
|
||||||
|
Λάβαμε την πληρωμή σου
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:0 0 32px;">Λάβαμε την πληρωμή σου <strong>{{ $amount }}</strong> για την παραγγελία <strong>#{{ $reference }}</strong>.</p>
|
||||||
|
|
||||||
|
@include('emails.partials.button', [
|
||||||
|
'url' => route('account.orders', ['locale' => 'el']),
|
||||||
|
'label' => 'Οι παραγγελίες μου',
|
||||||
|
])
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('footer')
|
||||||
|
<p style="margin:0;">
|
||||||
|
Έχεις ερώτηση για την παραγγελία σου;
|
||||||
|
<a href="{{ route('contact', ['locale' => 'el']) }}" style="color:#7a7a7a;text-decoration:underline;">Επικοινώνησε μαζί μας</a>.
|
||||||
|
</p>
|
||||||
|
@endsection
|
||||||
|
|||||||