Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.34% covered (success)
96.34%
79 / 82
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ThemeService
96.34% covered (success)
96.34%
79 / 82
84.62% covered (warning)
84.62%
11 / 13
27
0.00% covered (danger)
0.00%
0 / 1
 themesPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 discover
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
5.01
 sync
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 active
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 activeHandle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDefault
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 activePath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 activate
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 manifest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 customizerFields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 customizerValues
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 saveCustomizer
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 activeCustomizer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Services\Theme;
4
5use App\Models\Theme;
6use App\Service\CacheService;
7use Illuminate\Support\Collection;
8use Illuminate\Support\Facades\File;
9
10/**
11 * The storefront theme layer.
12 *
13 * A theme is a folder under resources/views/themes/<handle>/ with a
14 * theme.json manifest. Theme Blade files are used only by explicit visual
15 * slots (header, footer, hero and product card). Page and widget content is
16 * always rendered from shared views, so changing themes never replaces it.
17 *
18 * The `themes` table just tracks which theme is active + its customizer
19 * values; the catalogue itself is discovered from disk on sync().
20 */
21class ThemeService
22{
23    public function themesPath(): string
24    {
25        return resource_path('views/themes');
26    }
27
28    /** Discover manifests on disk + the synthetic "default". */
29    public function discover(): Collection
30    {
31        $found = collect([[
32            'handle' => Theme::DEFAULT,
33            'name' => 'Default',
34            'version' => (string) config('app.version', '1.0'),
35            'description' => 'The built-in storefront design.',
36            'screenshot' => 'themes/screenshots/default.svg',
37            'stylesheet' => null,
38            'customizer' => config('settings.appearance.fields', []),
39        ]]);
40
41        if (! File::isDirectory($this->themesPath())) {
42            return $found->keyBy('handle');
43        }
44
45        foreach (File::directories($this->themesPath()) as $dir) {
46            $manifest = $dir . '/theme.json';
47            if (! File::exists($manifest)) {
48                continue;
49            }
50
51            $json = json_decode(File::get($manifest), true) ?: [];
52            $handle = basename($dir);
53
54            $found->push([
55                'handle' => $handle,
56                'name' => $json['name'] ?? ucfirst($handle),
57                'version' => (string) ($json['version'] ?? '1.0'),
58                'description' => $json['description'] ?? '',
59                'screenshot' => $json['screenshot'] ?? null,
60                'stylesheet' => $json['stylesheet'] ?? null,
61                'customizer' => $json['customizer'] ?? [],
62            ]);
63        }
64
65        return $found->keyBy('handle');
66    }
67
68    /** Upsert a themes row for every discovered theme; prune the rest. */
69    public function sync(): void
70    {
71        $discovered = $this->discover();
72
73        foreach ($discovered as $t) {
74            Theme::query()->updateOrCreate(
75                ['handle' => $t['handle']],
76                ['name' => $t['name'], 'version' => $t['version']],
77            );
78        }
79
80        Theme::query()->whereNotIn('handle', $discovered->keys())->delete();
81
82        if (! Theme::query()->where('is_active', true)->exists()) {
83            Theme::query()->where('handle', Theme::DEFAULT)->update(['is_active' => true]);
84        }
85    }
86
87    /** @return array{handle:string,name:string,version:?string,settings:array} */
88    public function active(): array
89    {
90        return CacheService::remember(CacheService::KEY_THEMES, function () {
91            $row = Theme::query()->where('is_active', true)->first()
92                ?? Theme::query()->firstOrCreate(
93                    ['handle' => Theme::DEFAULT],
94                    ['name' => 'Default', 'is_active' => true],
95                );
96
97            return [
98                'handle' => $row->handle,
99                'name' => $row->name,
100                'version' => $row->version,
101                'settings' => $row->settings ?? [],
102            ];
103        });
104    }
105
106    public function activeHandle(): string
107    {
108        return $this->active()['handle'];
109    }
110
111    public function isDefault(): bool
112    {
113        return $this->activeHandle() === Theme::DEFAULT;
114    }
115
116    /** Absolute path of the active theme's view folder, or null for "default". */
117    public function activePath(): ?string
118    {
119        if ($this->isDefault()) {
120            return null;
121        }
122
123        $path = $this->themesPath() . '/' . $this->activeHandle();
124
125        return File::isDirectory($path) ? $path : null;
126    }
127
128    public function activate(string $handle): void
129    {
130        $this->sync();
131
132        if (! Theme::query()->where('handle', $handle)->exists()) {
133            return;
134        }
135
136        Theme::query()->update(['is_active' => false]);
137        Theme::query()->where('handle', $handle)->update(['is_active' => true]);
138
139        CacheService::forgetThemes();
140    }
141
142    /** Manifest (merged with disk discovery) for one theme. */
143    public function manifest(string $handle): ?array
144    {
145        return $this->discover()->get($handle);
146    }
147
148    /** Customizer field schema for a theme (theme.json "customizer"). */
149    public function customizerFields(string $handle): array
150    {
151        return $this->manifest($handle)['customizer'] ?? [];
152    }
153
154    /** Customizer values for a theme: stored → field defaults.
155     *  The "default" theme reuses the existing `appearance` settings group so
156     *  nothing changes for stores that never install a second theme. */
157    public function customizerValues(string $handle): array
158    {
159        if ($handle === Theme::DEFAULT) {
160            return settings()->all('appearance');
161        }
162
163        $stored = Theme::query()->where('handle', $handle)->value('settings') ?? [];
164
165        $values = [];
166        foreach ($this->customizerFields($handle) as $key => $def) {
167            $values[$key] = $stored[$key] ?? ($def['default'] ?? null);
168        }
169
170        return $values;
171    }
172
173    public function saveCustomizer(string $handle, array $values): void
174    {
175        $fields = $this->customizerFields($handle);
176
177        if ($handle === Theme::DEFAULT) {
178            foreach ($fields as $key => $def) {
179                settings()->set("appearance.$key", $values[$key] ?? ($def['default'] ?? null));
180            }
181
182            return;
183        }
184
185        $clean = [];
186        foreach ($fields as $key => $def) {
187            $clean[$key] = $values[$key] ?? ($def['default'] ?? null);
188        }
189
190        Theme::query()->updateOrCreate(['handle' => $handle], ['settings' => $clean]);
191
192        CacheService::forgetThemes();
193    }
194
195    /** Customizer values for the *active* theme — what the storefront renders with. */
196    public function activeCustomizer(): array
197    {
198        return $this->customizerValues($this->activeHandle());
199    }
200}